Files
WeFlow/src/pages/SettingsPage.tsx
2026-04-08 19:26:48 +08:00

3831 lines
153 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, UserRound,
Sparkles, Loader2, CheckCircle2, XCircle
} from 'lucide-react'
import { Avatar } from '../components/Avatar'
import './SettingsPage.scss'
type SettingsTab = 'appearance' | 'notification' | 'antiRevoke' | 'database' | 'models' | 'cache' | 'api' | 'updates' | 'security' | 'about' | 'analytics' | 'insight'
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
{ id: 'appearance', label: '外观', icon: Palette },
{ id: 'notification', label: '通知', icon: Bell },
{ id: 'antiRevoke', label: '防撤回', icon: RotateCcw },
{ 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: 'insight', label: 'AI 见解', icon: Sparkles },
{ id: 'security', label: '安全', icon: ShieldCheck },
{ id: 'updates', label: '版本更新', icon: RefreshCw },
{ id: 'about', label: '关于', icon: Info }
]
const isMac = navigator.userAgent.toLowerCase().includes('mac')
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
const isWindows = !isMac && !isLinux
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
const dbPathPlaceholder = isMac
? '例如: ~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9'
: isLinux
? '例如: ~/.local/share/WeChat/xwechat_files 或者 ~/Documents/xwechat_files'
: '例如: C:\\Users\\xxx\\Documents\\xwechat_files'
interface WxidOption {
wxid: string
modifiedTime: number
nickname?: string
avatarUrl?: string
}
interface SettingsPageProps {
onClose?: () => void
}
function SettingsPage({ onClose }: SettingsPageProps = {}) {
const location = useLocation()
const {
isDbConnected,
setDbConnected,
setLoading,
reset,
updateInfo,
setUpdateInfo,
isDownloading,
setIsDownloading,
downloadProgress,
setDownloadProgress,
showUpdateDialog,
setShowUpdateDialog,
} = useAppStore()
const chatSessions = useChatStore((state) => state.sessions)
const setChatSessions = useChatStore((state) => state.setSessions)
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<SettingsTab>('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<WxidOption[]>([])
const [showWxidSelect, setShowWxidSelect] = useState(false)
const [cachePath, setCachePath] = useState('')
const [imageKeyProgress, setImageKeyProgress] = useState(0)
const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(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 [httpApiToken, setHttpApiToken] = useState('')
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 generateRandomToken = async () => {
// 生成 32 字符的十六进制随机字符串 (16 bytes)
const array = new Uint8Array(16)
crypto.getRandomValues(array)
const token = Array.from(array).map(b => b.toString(16).padStart(2, '0')).join('')
setHttpApiToken(token)
await configService.setHttpApiToken(token)
showMessage('已生成<E7949F><E68890>保存新的 Access Token', true)
}
const clearApiToken = async () => {
setHttpApiToken('')
await configService.setHttpApiToken('')
showMessage('已清除 Access TokenAPI 将允许无鉴权访问', true)
}
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
const [notificationEnabled, setNotificationEnabled] = useState(true)
const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'>('top-right')
const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all')
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
const [launchAtStartup, setLaunchAtStartup] = useState(false)
const [launchAtStartupSupported, setLaunchAtStartupSupported] = useState(isWindows || isMac)
const [launchAtStartupReason, setLaunchAtStartupReason] = useState('')
const [windowCloseBehavior, setWindowCloseBehavior] = useState<configService.WindowCloseBehavior>('ask')
const [quoteLayout, setQuoteLayout] = useState<configService.QuoteLayout>('quote-top')
const [updateChannel, setUpdateChannel] = useState<configService.UpdateChannel>('stable')
const [filterSearchKeyword, setFilterSearchKeyword] = useState('')
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
const [closeBehaviorDropdownOpen, setCloseBehaviorDropdownOpen] = useState(false)
const [wordCloudExcludeWords, setWordCloudExcludeWords] = useState<string[]>([])
const [excludeWordsInput, setExcludeWordsInput] = useState('')
// 数据收集同意状态
const [analyticsConsent, setAnalyticsConsent] = useState<boolean>(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 [isUpdatingLaunchAtStartup, setIsUpdatingLaunchAtStartup] = 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<Record<string, ReturnType<typeof setTimeout>>>({})
// 安全设置 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 [httpApiHost, setHttpApiHost] = useState('127.0.0.1')
const [httpApiRunning, setHttpApiRunning] = useState(false)
const [httpApiMediaExportPath, setHttpApiMediaExportPath] = useState('')
const [isTogglingApi, setIsTogglingApi] = useState(false)
const [showApiWarning, setShowApiWarning] = useState(false)
const [messagePushEnabled, setMessagePushEnabled] = useState(false)
const [antiRevokeSearchKeyword, setAntiRevokeSearchKeyword] = useState('')
const [antiRevokeSelectedIds, setAntiRevokeSelectedIds] = useState<Set<string>>(new Set())
const [antiRevokeStatusMap, setAntiRevokeStatusMap] = useState<Record<string, { installed?: boolean; loading?: boolean; error?: string }>>({})
const [isAntiRevokeRefreshing, setIsAntiRevokeRefreshing] = useState(false)
const [isAntiRevokeInstalling, setIsAntiRevokeInstalling] = useState(false)
const [isAntiRevokeUninstalling, setIsAntiRevokeUninstalling] = useState(false)
const [antiRevokeSummary, setAntiRevokeSummary] = useState<{ action: 'refresh' | 'install' | 'uninstall'; success: number; failed: number } | null>(null)
const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache
// AI 见解 state
const [aiInsightEnabled, setAiInsightEnabled] = useState(false)
const [aiInsightApiBaseUrl, setAiInsightApiBaseUrl] = useState('')
const [aiInsightApiKey, setAiInsightApiKey] = useState('')
const [aiInsightApiModel, setAiInsightApiModel] = useState('gpt-4o-mini')
const [aiInsightSilenceDays, setAiInsightSilenceDays] = useState(3)
const [aiInsightAllowContext, setAiInsightAllowContext] = useState(false)
const [isTestingInsight, setIsTestingInsight] = useState(false)
const [insightTestResult, setInsightTestResult] = useState<{ success: boolean; message: string } | null>(null)
const [showInsightApiKey, setShowInsightApiKey] = useState(false)
const [isTriggeringInsightTest, setIsTriggeringInsightTest] = useState(false)
const [insightTriggerResult, setInsightTriggerResult] = useState<{ success: boolean; message: string } | null>(null)
const [aiInsightWhitelistEnabled, setAiInsightWhitelistEnabled] = useState(false)
const [aiInsightWhitelist, setAiInsightWhitelist] = useState<Set<string>>(new Set())
const [insightWhitelistSearch, setInsightWhitelistSearch] = useState('')
const [aiInsightCooldownMinutes, setAiInsightCooldownMinutes] = useState(120)
const [aiInsightScanIntervalHours, setAiInsightScanIntervalHours] = useState(4)
const [aiInsightContextCount, setAiInsightContextCount] = useState(40)
const [aiInsightSystemPrompt, setAiInsightSystemPrompt] = useState('')
const [aiInsightTelegramEnabled, setAiInsightTelegramEnabled] = useState(false)
const [aiInsightTelegramToken, setAiInsightTelegramToken] = useState('')
const [aiInsightTelegramChatIds, setAiInsightTelegramChatIds] = useState('')
const [isWayland, setIsWayland] = useState(false)
useEffect(() => {
const checkWaylandStatus = async () => {
if (window.electronAPI?.app?.checkWayland) {
try {
const wayland = await window.electronAPI.app.checkWayland()
setIsWayland(wayland)
} catch (e) {
console.error('检查 Wayland 状态失败:', e)
}
}
}
checkWaylandStatus()
}, [])
// 检查 Hello 可用性
useEffect(() => {
setHelloAvailable(isWindows)
}, [])
// 检查 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)
setCloseBehaviorDropdownOpen(false)
}
}
if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen) {
document.addEventListener('click', handleClickOutside)
}
return () => {
document.removeEventListener('click', handleClickOutside)
}
}, [closeBehaviorDropdownOpen, 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 savedMessagePushEnabled = await configService.getMessagePushEnabled()
const savedLaunchAtStartupStatus = await window.electronAPI.app.getLaunchAtStartupStatus()
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
const savedQuoteLayout = await configService.getQuoteLayout()
const savedUpdateChannel = await configService.getUpdateChannel()
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
const savedAuthUseHello = await configService.getAuthUseHello()
const savedIsLockMode = await window.electronAPI.auth.isLockMode()
const savedHttpApiToken = await configService.getHttpApiToken()
if (savedHttpApiToken) setHttpApiToken(savedHttpApiToken)
const savedApiPort = await configService.getHttpApiPort()
if (savedApiPort) setHttpApiPort(savedApiPort)
const savedApiHost = await configService.getHttpApiHost()
if (savedApiHost) setHttpApiHost(savedApiHost)
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)
setMessagePushEnabled(savedMessagePushEnabled)
setLaunchAtStartup(savedLaunchAtStartupStatus.enabled)
setLaunchAtStartupSupported(savedLaunchAtStartupStatus.supported)
setLaunchAtStartupReason(savedLaunchAtStartupStatus.reason || '')
setWindowCloseBehavior(savedWindowCloseBehavior)
setQuoteLayout(savedQuoteLayout)
if (savedUpdateChannel) {
setUpdateChannel(savedUpdateChannel)
} else {
const currentVersion = await window.electronAPI.app.getVersion()
if (/^0\.\d{2}\.\d+$/i.test(currentVersion) || /-preview\.\d+\.\d+$/i.test(currentVersion)) {
setUpdateChannel('preview')
} else if (/^\d{2}\.\d{1,2}\.\d{1,2}$/i.test(currentVersion) || /-dev\.\d+\.\d+\.\d+$/i.test(currentVersion) || /(alpha|beta|rc)/i.test(currentVersion)) {
setUpdateChannel('dev')
} else {
setUpdateChannel('stable')
}
}
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)
// 加载 AI 见解配置
const savedAiInsightEnabled = await configService.getAiInsightEnabled()
const savedAiInsightApiBaseUrl = await configService.getAiInsightApiBaseUrl()
const savedAiInsightApiKey = await configService.getAiInsightApiKey()
const savedAiInsightApiModel = await configService.getAiInsightApiModel()
const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays()
const savedAiInsightAllowContext = await configService.getAiInsightAllowContext()
const savedAiInsightWhitelistEnabled = await configService.getAiInsightWhitelistEnabled()
const savedAiInsightWhitelist = await configService.getAiInsightWhitelist()
const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes()
const savedAiInsightScanIntervalHours = await configService.getAiInsightScanIntervalHours()
const savedAiInsightContextCount = await configService.getAiInsightContextCount()
const savedAiInsightSystemPrompt = await configService.getAiInsightSystemPrompt()
const savedAiInsightTelegramEnabled = await configService.getAiInsightTelegramEnabled()
const savedAiInsightTelegramToken = await configService.getAiInsightTelegramToken()
const savedAiInsightTelegramChatIds = await configService.getAiInsightTelegramChatIds()
setAiInsightEnabled(savedAiInsightEnabled)
setAiInsightApiBaseUrl(savedAiInsightApiBaseUrl)
setAiInsightApiKey(savedAiInsightApiKey)
setAiInsightApiModel(savedAiInsightApiModel)
setAiInsightSilenceDays(savedAiInsightSilenceDays)
setAiInsightAllowContext(savedAiInsightAllowContext)
setAiInsightWhitelistEnabled(savedAiInsightWhitelistEnabled)
setAiInsightWhitelist(new Set(savedAiInsightWhitelist))
setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes)
setAiInsightScanIntervalHours(savedAiInsightScanIntervalHours)
setAiInsightContextCount(savedAiInsightContextCount)
setAiInsightSystemPrompt(savedAiInsightSystemPrompt)
setAiInsightTelegramEnabled(savedAiInsightTelegramEnabled)
setAiInsightTelegramToken(savedAiInsightTelegramToken)
setAiInsightTelegramChatIds(savedAiInsightTelegramChatIds)
} catch (e: any) {
console.error('加载配置失败:', e)
}
}
const handleLaunchAtStartupChange = async (enabled: boolean) => {
if (isUpdatingLaunchAtStartup) return
try {
setIsUpdatingLaunchAtStartup(true)
const result = await window.electronAPI.app.setLaunchAtStartup(enabled)
setLaunchAtStartup(result.enabled)
setLaunchAtStartupSupported(result.supported)
setLaunchAtStartupReason(result.reason || '')
if (result.success) {
showMessage(enabled ? '已开启开机自启动' : '已关闭开机自启动', true)
return
}
showMessage(result.error || result.reason || '设置开机自启动失败', false)
} catch (e: any) {
showMessage(`设置开机自启动失败: ${e?.message || String(e)}`, false)
} finally {
setIsUpdatingLaunchAtStartup(false)
}
}
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 handleUpdateChannelChange = async (channel: configService.UpdateChannel) => {
if (channel === updateChannel) return
try {
setUpdateChannel(channel)
await configService.setUpdateChannel(channel)
await configService.setIgnoredUpdateVersion('')
setUpdateInfo(null)
setShowUpdateDialog(false)
const channelLabel = channel === 'stable' ? '稳定版' : channel === 'preview' ? '预览版' : '开发版'
showMessage(`已切换到${channelLabel}更新渠道,正在检查更新`, true)
await handleCheckUpdate()
} catch (e: any) {
showMessage(`切换更新渠道<EFBFBD><EFBFBD>败: ${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)
}
const normalizeSessionIds = (sessionIds: string[]): string[] =>
Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
const getCurrentAntiRevokeSessionIds = (): string[] =>
normalizeSessionIds(chatSessions.map((session) => session.username))
const ensureAntiRevokeSessionsLoaded = async (): Promise<string[]> => {
const current = getCurrentAntiRevokeSessionIds()
if (current.length > 0) return current
const sessionsResult = await window.electronAPI.chat.getSessions()
if (!sessionsResult.success || !sessionsResult.sessions) {
throw new Error(sessionsResult.error || '加载会话失败')
}
setChatSessions(sessionsResult.sessions)
return normalizeSessionIds(sessionsResult.sessions.map((session) => session.username))
}
const markAntiRevokeRowsLoading = (sessionIds: string[]) => {
setAntiRevokeStatusMap((prev) => {
const next = { ...prev }
for (const sessionId of sessionIds) {
next[sessionId] = {
...(next[sessionId] || {}),
loading: true,
error: undefined
}
}
return next
})
}
const handleRefreshAntiRevokeStatus = async (sessionIds?: string[]) => {
if (isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling) return
setAntiRevokeSummary(null)
setIsAntiRevokeRefreshing(true)
try {
const targetIds = normalizeSessionIds(
sessionIds && sessionIds.length > 0
? sessionIds
: await ensureAntiRevokeSessionsLoaded()
)
if (targetIds.length === 0) {
setAntiRevokeStatusMap({})
showMessage('暂无可检查的会话', true)
return
}
markAntiRevokeRowsLoading(targetIds)
const result = await window.electronAPI.chat.checkAntiRevokeTriggers(targetIds)
if (!result.success || !result.rows) {
const errorText = result.error || '防撤回状态检查失败'
setAntiRevokeStatusMap((prev) => {
const next = { ...prev }
for (const sessionId of targetIds) {
next[sessionId] = {
...(next[sessionId] || {}),
loading: false,
error: errorText
}
}
return next
})
showMessage(errorText, false)
return
}
const rowMap = new Map<string, { sessionId: string; success: boolean; installed?: boolean; error?: string }>()
for (const row of result.rows || []) {
const sessionId = String(row.sessionId || '').trim()
if (!sessionId) continue
rowMap.set(sessionId, row)
}
const mergedRows = targetIds.map((sessionId) => (
rowMap.get(sessionId) || { sessionId, success: false, error: '状态查询未返回结果' }
))
const successCount = mergedRows.filter((row) => row.success).length
const failedCount = mergedRows.length - successCount
setAntiRevokeStatusMap((prev) => {
const next = { ...prev }
for (const row of mergedRows) {
const sessionId = String(row.sessionId || '').trim()
if (!sessionId) continue
next[sessionId] = {
installed: row.installed === true,
loading: false,
error: row.success ? undefined : (row.error || '状态查询失败')
}
}
return next
})
setAntiRevokeSummary({ action: 'refresh', success: successCount, failed: failedCount })
showMessage(`状态刷新完成:成功 ${successCount},失败 ${failedCount}`, failedCount === 0)
} catch (e: any) {
showMessage(`防撤回状态刷新失败: ${e?.message || String(e)}`, false)
} finally {
setIsAntiRevokeRefreshing(false)
}
}
const handleInstallAntiRevokeTriggers = async () => {
if (isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling) return
const sessionIds = normalizeSessionIds(Array.from(antiRevokeSelectedIds))
if (sessionIds.length === 0) {
showMessage('请先选择至少一个会话', false)
return
}
setAntiRevokeSummary(null)
setIsAntiRevokeInstalling(true)
try {
markAntiRevokeRowsLoading(sessionIds)
const result = await window.electronAPI.chat.installAntiRevokeTriggers(sessionIds)
if (!result.success || !result.rows) {
const errorText = result.error || '批量安装失败'
setAntiRevokeStatusMap((prev) => {
const next = { ...prev }
for (const sessionId of sessionIds) {
next[sessionId] = {
...(next[sessionId] || {}),
loading: false,
error: errorText
}
}
return next
})
showMessage(errorText, false)
return
}
const rowMap = new Map<string, { sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>()
for (const row of result.rows || []) {
const sessionId = String(row.sessionId || '').trim()
if (!sessionId) continue
rowMap.set(sessionId, row)
}
const mergedRows = sessionIds.map((sessionId) => (
rowMap.get(sessionId) || { sessionId, success: false, error: '安装未返回结果' }
))
const successCount = mergedRows.filter((row) => row.success).length
const failedCount = mergedRows.length - successCount
setAntiRevokeStatusMap((prev) => {
const next = { ...prev }
for (const row of mergedRows) {
const sessionId = String(row.sessionId || '').trim()
if (!sessionId) continue
next[sessionId] = {
installed: row.success ? true : next[sessionId]?.installed,
loading: false,
error: row.success ? undefined : (row.error || '安装失败')
}
}
return next
})
setAntiRevokeSummary({ action: 'install', success: successCount, failed: failedCount })
showMessage(`批量安装完成:成功 ${successCount},失败 ${failedCount}`, failedCount === 0)
} catch (e: any) {
showMessage(`批量安装失败: ${e?.message || String(e)}`, false)
} finally {
setIsAntiRevokeInstalling(false)
}
}
const handleUninstallAntiRevokeTriggers = async () => {
if (isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling) return
const sessionIds = normalizeSessionIds(Array.from(antiRevokeSelectedIds))
if (sessionIds.length === 0) {
showMessage('请先选择至少一个会话', false)
return
}
setAntiRevokeSummary(null)
setIsAntiRevokeUninstalling(true)
try {
markAntiRevokeRowsLoading(sessionIds)
const result = await window.electronAPI.chat.uninstallAntiRevokeTriggers(sessionIds)
if (!result.success || !result.rows) {
const errorText = result.error || '批量卸载失败'
setAntiRevokeStatusMap((prev) => {
const next = { ...prev }
for (const sessionId of sessionIds) {
next[sessionId] = {
...(next[sessionId] || {}),
loading: false,
error: errorText
}
}
return next
})
showMessage(errorText, false)
return
}
const rowMap = new Map<string, { sessionId: string; success: boolean; error?: string }>()
for (const row of result.rows || []) {
const sessionId = String(row.sessionId || '').trim()
if (!sessionId) continue
rowMap.set(sessionId, row)
}
const mergedRows = sessionIds.map((sessionId) => (
rowMap.get(sessionId) || { sessionId, success: false, error: '卸载未返回结果' }
))
const successCount = mergedRows.filter((row) => row.success).length
const failedCount = mergedRows.length - successCount
setAntiRevokeStatusMap((prev) => {
const next = { ...prev }
for (const row of mergedRows) {
const sessionId = String(row.sessionId || '').trim()
if (!sessionId) continue
next[sessionId] = {
installed: row.success ? false : next[sessionId]?.installed,
loading: false,
error: row.success ? undefined : (row.error || '卸载失败')
}
}
return next
})
setAntiRevokeSummary({ action: 'uninstall', success: successCount, failed: failedCount })
showMessage(`批量卸载完成:成功 ${successCount},失败 ${failedCount}`, failedCount === 0)
} catch (e: any) {
showMessage(`批量卸载失败: ${e?.message || String(e)}`, false)
} finally {
setIsAntiRevokeUninstalling(false)
}
}
useEffect(() => {
if (activeTab !== 'antiRevoke' && activeTab !== 'insight') return
let canceled = false
;(async () => {
try {
// 两个 Tab 都需要会话列表antiRevoke 还需要额外检查防撤回状态
const sessionIds = await ensureAntiRevokeSessionsLoaded()
if (canceled) return
if (activeTab === 'antiRevoke') {
await handleRefreshAntiRevokeStatus(sessionIds)
}
} catch (e: any) {
if (!canceled) {
showMessage(`加载会话失败: ${e?.message || String(e)}`, false)
}
}
})()
return () => {
canceled = true
}
}, [activeTab])
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 validatePath = (path: string): string | null => {
if (!path) return null
if (/[\u4e00-\u9fa5]/.test(path)) {
return '路径包含中文字符,请迁移至全英文目录'
}
return null
}
const handleAutoDetectPath = async () => {
if (isDetectingPath) return
setIsDetectingPath(true)
try {
const result = await window.electronAPI.dbPath.autoDetect()
if (result.success && result.path) {
const validationError = validatePath(result.path)
if (validationError) {
showMessage(validationError, false)
} else {
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]
const validationError = validatePath(selectedPath)
if (validationError) {
showMessage(validationError, false)
} else {
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> | 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('已获取图片<E59BBE><E78987>钥')
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 handleClearLog = async () => {
const confirmed = window.confirm('确定清空 wcdb.log 吗?')
if (!confirmed) return
try {
const result = await window.electronAPI.log.clear()
if (!result.success) {
showMessage(result.error || '清空日志失败', false)
return
}
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 = () => (
<div className="tab-content">
<div className="theme-mode-toggle">
<button className={`mode-btn ${themeMode === 'light' ? 'active' : ''}`} onClick={() => setThemeMode('light')}>
<Sun size={16} />
</button>
<button className={`mode-btn ${themeMode === 'dark' ? 'active' : ''}`} onClick={() => setThemeMode('dark')}>
<Moon size={16} />
</button>
<button className={`mode-btn ${themeMode === 'system' ? 'active' : ''}`} onClick={() => setThemeMode('system')}>
<Monitor size={16} />
</button>
</div>
<div className="theme-grid">
{themes.map((theme) => (
<div key={theme.id} className={`theme-card ${currentTheme === theme.id ? 'active' : ''}`} onClick={() => setTheme(theme.id)}>
<div className="theme-preview" style={{
background: effectiveMode === 'dark'
? (theme.id === 'blossom-dream' ? 'linear-gradient(150deg, #151316 0%, #1A1620 50%, #131018 100%)'
: theme.id === 'geist' ? 'linear-gradient(135deg, #1a1a1a 0%, #222222 100%)'
: 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)')
: (theme.id === 'blossom-dream' ? `linear-gradient(150deg, ${theme.bgColor} 0%, #F8F2F8 45%, #F2F6FB 100%)`
: theme.id === 'geist' ? 'linear-gradient(135deg, #ffffff 0%, #f0f0f0 100%)'
: `linear-gradient(135deg, ${theme.bgColor} 0%, ${theme.bgColor}dd 100%)`)
}}>
<div className="theme-accent" style={{
background: theme.accentColor
? `linear-gradient(135deg, ${theme.primaryColor} 0%, ${theme.accentColor} 100%)`
: theme.primaryColor
}} />
</div>
<div className="theme-info">
<span className="theme-name">{theme.name}</span>
<span className="theme-desc">{theme.description}</span>
</div>
{currentTheme === theme.id && <div className="theme-check"><Check size={14} /></div>}
</div>
))}
</div>
<div className="form-group quote-layout-group">
<label></label>
<span className="form-hint"></span>
<div className="quote-layout-picker" role="radiogroup" aria-label="引用样式选择">
{[
{
value: 'quote-top' as const,
label: '引用在上',
description: '更接近当前 WeFlow 风格',
successMessage: '已切换为引用在上样式'
},
{
value: 'quote-bottom' as const,
label: '正文在上',
description: '更接近微信 / 密语风格',
successMessage: '已切换为正文在上样式'
}
].map(option => {
const selected = quoteLayout === option.value
const isQuoteBottom = option.value === 'quote-bottom'
return (
<button
key={option.value}
type="button"
className={`quote-layout-card ${selected ? 'active' : ''}`}
onClick={async () => {
if (selected) return
setQuoteLayout(option.value)
await configService.setQuoteLayout(option.value)
showMessage(option.successMessage, true)
}}
role="radio"
aria-checked={selected}
>
<span className={`quote-layout-card-check ${selected ? 'active' : ''}`} aria-hidden="true" />
<div className="quote-layout-preview-shell">
<div className="quote-layout-preview-chat">
<div className="message-bubble sent">
<div className={`bubble-content ${isQuoteBottom ? 'quote-layout-bottom' : 'quote-layout-top'}`}>
{isQuoteBottom ? (
<>
<div className="message-text">!</div>
<div className="quoted-message">
<span className="quoted-sender"></span>
<span className="quoted-text">...</span>
</div>
</>
) : (
<>
<div className="quoted-message">
<span className="quoted-sender"></span>
<span className="quoted-text">...</span>
</div>
<div className="message-text">!</div>
</>
)}
</div>
</div>
</div>
</div>
<div className="quote-layout-card-footer">
<div className="quote-layout-card-title-group">
<span className="quote-layout-card-title">{option.label}</span>
<span className="quote-layout-card-desc">{option.description}</span>
</div>
</div>
</button>
)
})}
</div>
</div>
<div className="divider" />
<div className="form-group">
<label></label>
<span className="form-hint">
{launchAtStartupSupported
? '开启后,登录系统时会自动启动 WeFlow。'
: launchAtStartupReason || '当前环境暂不支持开机自启动。'}
</span>
<div className="log-toggle-line">
<span className="log-status">
{isUpdatingLaunchAtStartup
? '保存中...'
: launchAtStartupSupported
? (launchAtStartup ? '已开启' : '已关闭')
: '当前不可用'}
</span>
<label className="switch" htmlFor="launch-at-startup-toggle">
<input
id="launch-at-startup-toggle"
className="switch-input"
type="checkbox"
checked={launchAtStartup}
disabled={!launchAtStartupSupported || isUpdatingLaunchAtStartup}
onChange={(e) => {
void handleLaunchAtStartupChange(e.target.checked)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="divider" />
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="custom-select">
<div
className={`custom-select-trigger ${closeBehaviorDropdownOpen ? 'open' : ''}`}
onClick={() => setCloseBehaviorDropdownOpen(!closeBehaviorDropdownOpen)}
>
<span className="custom-select-value">
{windowCloseBehavior === 'tray'
? '最小化到系统托盘'
: windowCloseBehavior === 'quit'
? '完全关闭'
: '每次询问'}
</span>
<ChevronDown size={14} className={`custom-select-arrow ${closeBehaviorDropdownOpen ? 'rotate' : ''}`} />
</div>
<div className={`custom-select-dropdown ${closeBehaviorDropdownOpen ? 'open' : ''}`}>
{[
{
value: 'ask' as const,
label: '每次询问',
successMessage: '已恢复关闭确认弹窗'
},
{
value: 'tray' as const,
label: '最小化到系统托盘',
successMessage: '关闭按钮已改为最小化到托盘'
},
{
value: 'quit' as const,
label: '完全关闭',
successMessage: '关闭按钮已改为完全关闭'
}
].map(option => (
<div
key={option.value}
className={`custom-select-option ${windowCloseBehavior === option.value ? 'selected' : ''}`}
onClick={async () => {
setWindowCloseBehavior(option.value)
setCloseBehaviorDropdownOpen(false)
await configService.setWindowCloseBehavior(option.value)
showMessage(option.successMessage, true)
}}
>
{option.label}
{windowCloseBehavior === option.value && <Check size={14} />}
</div>
))}
</div>
</div>
</div>
</div>
)
const renderNotificationTab = () => {
// 获取已过滤会话的信息
const getSessionInfo = (username: string) => {
const session = chatSessions.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 = chatSessions.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 (
<div className="tab-content">
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="log-toggle-line">
<span className="log-status">{notificationEnabled ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="notification-enabled-toggle">
<input
id="notification-enabled-toggle"
className="switch-input"
type="checkbox"
checked={notificationEnabled}
onChange={async (e) => {
const val = e.target.checked
setNotificationEnabled(val)
await configService.setNotificationEnabled(val)
showMessage(val ? '已开启通知' : '已关闭通知', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"></span>
{isWayland && (
<span className="form-hint" style={{ color: '#ff4d4f', marginTop: '4px', display: 'block' }}>
Wayland
</span>
)}
<div className="custom-select">
<div
className={`custom-select-trigger ${positionDropdownOpen ? 'open' : ''}`}
onClick={() => setPositionDropdownOpen(!positionDropdownOpen)}
>
<span className="custom-select-value">
{notificationPosition === 'top-right' ? '右上角' :
notificationPosition === 'bottom-right' ? '右下角' :
notificationPosition === 'top-left' ? '左上角' :
notificationPosition === 'top-center' ? '中间上方' : '左下角'}
</span>
<ChevronDown size={14} className={`custom-select-arrow ${positionDropdownOpen ? 'rotate' : ''}`} />
</div>
<div className={`custom-select-dropdown ${positionDropdownOpen ? 'open' : ''}`}>
{[
{ value: 'top-center', label: '中间上方' },
{ value: 'top-right', label: '右上角' },
{ value: 'bottom-right', label: '右下角' },
{ value: 'top-left', label: '左上角' },
{ value: 'bottom-left', label: '左下角' }
].map(option => (
<div
key={option.value}
className={`custom-select-option ${notificationPosition === option.value ? 'selected' : ''}`}
onClick={async () => {
const val = option.value as 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
setNotificationPosition(val)
setPositionDropdownOpen(false)
await configService.setNotificationPosition(val)
showMessage('通知位置已更新', true)
}}
>
{option.label}
{notificationPosition === option.value && <Check size={14} />}
</div>
))}
</div>
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="custom-select">
<div
className={`custom-select-trigger ${filterModeDropdownOpen ? 'open' : ''}`}
onClick={() => setFilterModeDropdownOpen(!filterModeDropdownOpen)}
>
<span className="custom-select-value">
{notificationFilterMode === 'all' ? '接收所有通知' :
notificationFilterMode === 'whitelist' ? '仅接收白名单' : '屏蔽黑名单'}
</span>
<ChevronDown size={14} className={`custom-select-arrow ${filterModeDropdownOpen ? 'rotate' : ''}`} />
</div>
<div className={`custom-select-dropdown ${filterModeDropdownOpen ? 'open' : ''}`}>
{[
{ value: 'all', label: '接收所有通知' },
{ value: 'whitelist', label: '仅接收白名单' },
{ value: 'blacklist', label: '屏蔽黑名单' }
].map(option => (
<div
key={option.value}
className={`custom-select-option ${notificationFilterMode === option.value ? 'selected' : ''}`}
onClick={async () => {
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 && <Check size={14} />}
</div>
))}
</div>
</div>
</div>
{notificationFilterMode !== 'all' && (
<div className="form-group">
<label>{notificationFilterMode === 'whitelist' ? '白名单会话' : '黑名单会话'}</label>
<span className="form-hint">
{notificationFilterMode === 'whitelist'
? '点击左侧会话添加到白名单,点击右侧会话从白名单移除'
: '点击左侧会话添加到黑名单,点击右侧会话从黑名单移除'}
</span>
<div className="notification-filter-container">
{/* 可选会话列表 */}
<div className="filter-panel">
<div className="filter-panel-header">
<span></span>
<div className="filter-search-box">
<Search size={14} />
<input
type="text"
placeholder="搜索会话..."
value={filterSearchKeyword}
onChange={(e) => setFilterSearchKeyword(e.target.value)}
/>
</div>
</div>
<div className="filter-panel-list">
{availableSessions.length > 0 ? (
availableSessions.map(session => (
<div
key={session.username}
className="filter-panel-item"
onClick={() => handleAddToFilterList(session.username)}
>
<Avatar
src={session.avatarUrl}
name={session.displayName || session.username}
size={28}
/>
<span className="filter-item-name">{session.displayName || session.username}</span>
<span className="filter-item-action">+</span>
</div>
))
) : (
<div className="filter-panel-empty">
{filterSearchKeyword ? '没有匹配的会话' : '暂无可添加的会话'}
</div>
)}
</div>
</div>
{/* 已选会话列表 */}
<div className="filter-panel">
<div className="filter-panel-header">
<span>{notificationFilterMode === 'whitelist' ? '白名单' : '黑名单'}</span>
{notificationFilterList.length > 0 && (
<span className="filter-panel-count">{notificationFilterList.length}</span>
)}
</div>
<div className="filter-panel-list">
{notificationFilterList.length > 0 ? (
notificationFilterList.map(username => {
const info = getSessionInfo(username)
return (
<div
key={username}
className="filter-panel-item selected"
onClick={() => handleRemoveFromFilterList(username)}
>
<Avatar
src={info.avatarUrl}
name={info.displayName}
size={28}
/>
<span className="filter-item-name">{info.displayName}</span>
<span className="filter-item-action">×</span>
</div>
)
})
) : (
<div className="filter-panel-empty"></div>
)}
</div>
</div>
</div>
</div>
)}
</div>
)
}
const renderAntiRevokeTab = () => {
const sortedSessions = [...chatSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
const keyword = antiRevokeSearchKeyword.trim().toLowerCase()
const filteredSessions = sortedSessions.filter((session) => {
if (!keyword) return true
const displayName = String(session.displayName || '').toLowerCase()
const username = String(session.username || '').toLowerCase()
return displayName.includes(keyword) || username.includes(keyword)
})
const filteredSessionIds = filteredSessions.map((session) => session.username)
const selectedCount = antiRevokeSelectedIds.size
const selectedInFilteredCount = filteredSessionIds.filter((sessionId) => antiRevokeSelectedIds.has(sessionId)).length
const allFilteredSelected = filteredSessionIds.length > 0 && selectedInFilteredCount === filteredSessionIds.length
const busy = isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling
const statusStats = filteredSessions.reduce(
(acc, session) => {
const rowState = antiRevokeStatusMap[session.username]
if (rowState?.error) acc.failed += 1
else if (rowState?.installed === true) acc.installed += 1
else if (rowState?.installed === false) acc.notInstalled += 1
return acc
},
{ installed: 0, notInstalled: 0, failed: 0 }
)
const toggleSelected = (sessionId: string) => {
setAntiRevokeSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(sessionId)) next.delete(sessionId)
else next.add(sessionId)
return next
})
}
const selectAllFiltered = () => {
if (filteredSessionIds.length === 0) return
setAntiRevokeSelectedIds((prev) => {
const next = new Set(prev)
for (const sessionId of filteredSessionIds) {
next.add(sessionId)
}
return next
})
}
const clearSelection = () => {
setAntiRevokeSelectedIds(new Set())
}
return (
<div className="tab-content anti-revoke-tab">
<div className="anti-revoke-hero">
<div className="anti-revoke-hero-main">
<h3></h3>
<p> WeFlow </p>
</div>
<div className="anti-revoke-metrics">
<div className="anti-revoke-metric is-total">
<span className="label"></span>
<span className="value">{filteredSessionIds.length}</span>
</div>
<div className="anti-revoke-metric is-installed">
<span className="label"></span>
<span className="value">{statusStats.installed}</span>
</div>
<div className="anti-revoke-metric is-pending">
<span className="label"></span>
<span className="value">{statusStats.notInstalled}</span>
</div>
<div className="anti-revoke-metric is-error">
<span className="label"></span>
<span className="value">{statusStats.failed}</span>
</div>
</div>
</div>
<div className="anti-revoke-control-card">
<div className="anti-revoke-toolbar">
<div className="filter-search-box anti-revoke-search">
<Search size={14} />
<input
type="text"
placeholder="搜索会话..."
value={antiRevokeSearchKeyword}
onChange={(e) => setAntiRevokeSearchKeyword(e.target.value)}
/>
</div>
<div className="anti-revoke-toolbar-actions">
<div className="anti-revoke-btn-group">
<button className="btn btn-secondary btn-sm" onClick={() => void handleRefreshAntiRevokeStatus()} disabled={busy}>
<RefreshCw size={14} /> {isAntiRevokeRefreshing ? '刷新中...' : '刷新状态'}
</button>
</div>
<div className="anti-revoke-btn-group">
<button className="btn btn-secondary btn-sm" onClick={selectAllFiltered} disabled={busy || filteredSessionIds.length === 0 || allFilteredSelected}>
</button>
<button className="btn btn-secondary btn-sm" onClick={clearSelection} disabled={busy || selectedCount === 0}>
</button>
</div>
</div>
</div>
<div className="anti-revoke-batch-actions">
<div className="anti-revoke-btn-group anti-revoke-batch-btns">
<button className="btn btn-primary btn-sm" onClick={() => void handleInstallAntiRevokeTriggers()} disabled={busy || selectedCount === 0}>
{isAntiRevokeInstalling ? '安装中...' : '批量安装'}
</button>
<button className="btn btn-secondary btn-sm" onClick={() => void handleUninstallAntiRevokeTriggers()} disabled={busy || selectedCount === 0}>
{isAntiRevokeUninstalling ? '卸载中...' : '批量卸载'}
</button>
</div>
<div className="anti-revoke-selected-count">
<span> <strong>{selectedCount}</strong> </span>
<span> <strong>{selectedInFilteredCount}</strong> / {filteredSessionIds.length}</span>
</div>
</div>
</div>
{antiRevokeSummary && (
<div className={`anti-revoke-summary ${antiRevokeSummary.failed > 0 ? 'error' : 'success'}`}>
{antiRevokeSummary.action === 'refresh' ? '刷新' : antiRevokeSummary.action === 'install' ? '安装' : '卸载'}
{antiRevokeSummary.success} {antiRevokeSummary.failed}
</div>
)}
<div className="anti-revoke-list">
{filteredSessions.length === 0 ? (
<div className="anti-revoke-empty">{antiRevokeSearchKeyword ? '没有匹配的会话' : '暂无会话可配置'}</div>
) : (
<>
<div className="anti-revoke-list-header">
<span>{filteredSessions.length}</span>
<span></span>
</div>
{filteredSessions.map((session) => {
const rowState = antiRevokeStatusMap[session.username]
let statusClass = 'unknown'
let statusLabel = '未检查'
if (rowState?.loading) {
statusClass = 'checking'
statusLabel = '检查中'
} else if (rowState?.error) {
statusClass = 'error'
statusLabel = '失败'
} else if (rowState?.installed === true) {
statusClass = 'installed'
statusLabel = '已安装'
} else if (rowState?.installed === false) {
statusClass = 'not-installed'
statusLabel = '未安装'
}
return (
<div key={session.username} className={`anti-revoke-row ${antiRevokeSelectedIds.has(session.username) ? 'selected' : ''}`}>
<label className="anti-revoke-row-main">
<span className="anti-revoke-check">
<input
type="checkbox"
checked={antiRevokeSelectedIds.has(session.username)}
onChange={() => toggleSelected(session.username)}
disabled={busy}
/>
<span className="check-indicator" aria-hidden="true">
<Check size={12} />
</span>
</span>
<Avatar
src={session.avatarUrl}
name={session.displayName || session.username}
size={30}
/>
<div className="anti-revoke-row-text">
<span className="name">{session.displayName || session.username}</span>
</div>
</label>
<div className="anti-revoke-row-status">
<span className={`status-badge ${statusClass}`}>
<i className="status-dot" aria-hidden="true" />
{statusLabel}
</span>
{rowState?.error && <span className="status-error">{rowState.error}</span>}
</div>
</div>
)
})}
</>
)}
</div>
</div>
)
}
const renderDatabaseTab = () => (
<div className="tab-content">
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<button className="btn btn-secondary" onClick={handleTestConnection} disabled={isLoading || isTesting}>
<Plug size={16} /> {isTesting ? '测试中...' : '测试连接'}
</button>
</div>
<div className="divider" />
<div className="form-group">
<label></label>
<span className="form-hint">64</span>
<div className="input-with-toggle">
<input
type={showDecryptKey ? 'text' : 'password'}
placeholder="例如: a1b2c3d4e5f6..."
value={decryptKey}
onChange={(e) => {
const value = e.target.value
setDecryptKey(value)
if (value && value.length === 64) {
scheduleConfigSave('keys', () => syncCurrentKeys({ decryptKey: value, wxid }))
// showMessage('解密密钥已保存', true)
}
}}
/>
<button type="button" className="toggle-visibility" onClick={() => setShowDecryptKey(!showDecryptKey)}>
{showDecryptKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
{isManualStartPrompt ? (
<div className="manual-prompt">
<p className="prompt-text"></p>
<button className="btn btn-primary btn-sm" onClick={handleManualConfirm}>
</button>
</div>
) : (
<button className="btn btn-secondary btn-sm" onClick={handleAutoGetDbKey} disabled={isFetchingDbKey}>
<Plug size={14} /> {isFetchingDbKey ? '获取中...' : '自动获取密钥'}
</button>
)}
{dbKeyStatus && <div className="form-hint status-text">{dbKeyStatus}</div>}
</div>
<div className="form-group">
<label></label>
<span className="form-hint">xwechat_files </span>
<input
type="text"
placeholder={dbPathPlaceholder}
value={dbPath}
onChange={(e) => {
const value = e.target.value
setDbPath(value)
scheduleConfigSave('dbPath', async () => {
if (value) {
await configService.setDbPath(value)
}
})
}}
/>
<div className="btn-row">
<button className="btn btn-primary" onClick={handleAutoDetectPath} disabled={isDetectingPath}>
<FolderSearch size={16} /> {isDetectingPath ? '检测中...' : '自动检测'}
</button>
<button className="btn btn-secondary" onClick={handleSelectDbPath}><FolderOpen size={16} /> </button>
</div>
</div>
<div className="form-group">
<label> wxid</label>
<span className="form-hint"></span>
<div className="wxid-input-wrapper">
<input
type="text"
placeholder="例如: wxid_xxxxxx"
value={wxid}
onChange={(e) => {
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 } }))
}
})
}}
/>
</div>
<button className="btn btn-secondary btn-sm" onClick={() => handleScanWxid()}><Search size={14} /> wxid</button>
</div>
<div className="form-group">
<label> XOR <span className="optional">()</span></label>
<span className="form-hint"></span>
<input
type="text"
placeholder="例如: 0xA4"
value={imageXorKey}
onChange={(e) => {
const value = e.target.value
setImageXorKey(value)
const parsed = parseImageXorKey(value)
if (value === '' || parsed !== null) {
scheduleConfigSave('keys', () => syncCurrentKeys({ imageXorKey: value, wxid }))
}
}}
/>
</div>
<div className="form-group">
<label> AES <span className="optional">()</span></label>
<span className="form-hint">16 </span>
<input
type="text"
placeholder="16 位 AES 密钥"
value={imageAesKey}
onChange={(e) => {
const value = e.target.value
setImageAesKey(value)
scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: value, wxid }))
}}
/>
<div style={{ display: 'flex', gap: '8px', marginTop: '4px' }}>
<button className="btn btn-primary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算">
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '缓存计算(推荐)'}
</button>
<button className="btn btn-secondary btn-sm" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存">
{isFetchingImageKey ? '扫描中...' : '内存扫描'}
</button>
</div>
{isFetchingImageKey ? (
<div className="brute-force-progress">
<div className="status-header">
<span className="status-text">{imageKeyStatus || '正在启动...'}</span>
</div>
</div>
) : (
imageKeyStatus && <div className="form-hint status-text" style={{ marginTop: '8px' }}>{imageKeyStatus}</div>
)}
<span className="form-hint">使 2-3 </span>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"> WCDB 便</span>
<div className="log-toggle-line">
<span className="log-status">{logEnabled ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="log-enabled-toggle">
<input
id="log-enabled-toggle"
className="switch-input"
type="checkbox"
checked={logEnabled}
onChange={async (e) => {
const enabled = e.target.checked
setLogEnabled(enabled)
await configService.setLogEnabled(enabled)
showMessage(enabled ? '已开启日志' : '已关闭日志', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
<div className="log-actions">
<button className="btn btn-secondary" onClick={handleOpenLog}>
<FolderOpen size={16} />
</button>
<button className="btn btn-secondary" onClick={handleCopyLog}>
<Copy size={16} />
</button>
<button className="btn btn-secondary" onClick={handleClearLog}>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
)
const resolvedWhisperModelPath = whisperModelDir || whisperModelStatus?.modelPath || ''
const renderModelsTab = () => (
<div className="tab-content">
<div className="form-group">
<label></label>
<span className="form-hint"></span>
</div>
<div className="form-group">
<label> (Whisper)</label>
<span className="form-hint"></span>
<div className="setting-control vertical has-border">
<div className="model-status-card">
<div className="model-info">
<div className="model-name-row">
<div className="model-name">SenseVoiceSmall</div>
<span className="model-size">245 MB</span>
</div>
<div className="model-meta">
{whisperModelStatus?.exists ? (
<span className="status-indicator success"><Check size={14} /> </span>
) : (
<span className="status-indicator warning"></span>
)}
{resolvedWhisperModelPath && (
<div className="model-path-block">
<span className="path-label"></span>
<div className="path-text" title={resolvedWhisperModelPath}>{resolvedWhisperModelPath}</div>
</div>
)}
</div>
</div>
{(!whisperModelStatus?.exists || isWhisperDownloading) && (
<div className="model-actions">
{!whisperModelStatus?.exists && !isWhisperDownloading && (
<button
className="btn-download"
onClick={handleDownloadWhisperModel}
>
<Download size={16} />
</button>
)}
{isWhisperDownloading && (
<div className="download-status">
<div className="status-header">
<span className="percent">{Math.round(whisperDownloadProgress)}%</span>
{whisperProgressData.total > 0 && (
<span className="details">
{formatBytes(whisperProgressData.downloaded)} / {formatBytes(whisperProgressData.total)}
<span className="speed">({formatBytes(whisperProgressData.speed)}/s)</span>
</span>
)}
</div>
<div className="progress-bar-mini">
<div className="fill" style={{ width: `${whisperDownloadProgress}%` }}></div>
</div>
</div>
)}
</div>
)}
</div>
<div className="sub-setting">
<div className="sub-label"></div>
<div className="path-selector">
<input
type="text"
value={whisperModelDir}
readOnly
placeholder="默认目录"
/>
<button className="btn-icon" onClick={handleSelectWhisperModelDir} title="选择目录">
<FolderOpen size={18} />
</button>
{whisperModelDir && (
<button className="btn-icon danger" onClick={handleResetWhisperModelDir} title="重置为默认">
<RotateCcw size={18} />
</button>
)}
</div>
</div>
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="log-toggle-line">
<span className="log-status">{autoTranscribeVoice ? '已开启' : '已关闭'}</span>
<label className="switch">
<input
type="checkbox"
className="switch-input"
checked={autoTranscribeVoice}
onChange={(e) => {
setAutoTranscribeVoice(e.target.checked)
configService.setAutoTranscribeVoice(e.target.checked)
}}
/>
<span className="switch-slider"></span>
</label>
</div>
</div>
</div>
)
const renderCacheTab = () => (
<div className="tab-content">
<p className="section-desc"></p>
<div className="form-group">
<label> <span className="optional">()</span></label>
<span className="form-hint">使</span>
<input
type="text"
placeholder="留空使用默认目录"
value={cachePath}
onChange={(e) => {
const value = e.target.value
setCachePath(value)
scheduleConfigSave('cachePath', () => configService.setCachePath(value))
}}
/>
<div style={{ marginTop: '8px', fontSize: '13px', color: 'var(--text-secondary)' }}>
<code style={{
background: 'var(--bg-secondary)',
padding: '3px 6px',
borderRadius: '4px',
userSelect: 'all',
wordBreak: 'break-all',
marginLeft: '4px'
}}>
{cachePath || (isMac ? '~/Documents/WeFlow' : isLinux ? '~/Documents/WeFlow' : '系统 文档\\WeFlow 目录')}
</code>
</div>
<div className="btn-row" style={{ marginTop: '12px' }}>
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> </button>
<button
className="btn btn-secondary"
onClick={async () => {
setCachePath('')
await configService.setCachePath('')
}}
>
<RotateCcw size={16} />
</button>
</div>
</div>
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleClearAnalyticsCache} disabled={isClearingCache}>
<Trash2 size={16} />
</button>
<button className="btn btn-secondary" onClick={handleClearImageCache} disabled={isClearingCache}>
<Trash2 size={16} />
</button>
<button className="btn btn-danger" onClick={handleClearAllCache} disabled={isClearingCache}>
<Trash2 size={16} /> </button>
</div>
<div className="divider" />
<p className="section-desc"></p>
<div className="btn-row">
<button className="btn btn-danger" onClick={handleClearConfig}>
<RefreshCw size={16} />
</button>
</div>
</div>
)
// HTTP API 服务控制
const handleToggleApi = async () => {
if (isTogglingApi) return
// 启动时显示警告弹窗
if (!httpApiRunning) {
setShowApiWarning(true)
return
}
setIsTogglingApi(true)
try {
await window.electronAPI.http.stop()
setHttpApiRunning(false)
await configService.setHttpApiEnabled(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, httpApiHost)
if (result.success) {
setHttpApiRunning(true)
if (result.port) setHttpApiPort(result.port)
await configService.setHttpApiEnabled(true)
await configService.setHttpApiPort(result.port || httpApiPort)
showMessage(`API 服务已启动,端口 ${result.port}`, true)
} else {
showMessage(`启动失败: ${result.error}`, false)
}
} catch (e: any) {
showMessage(`操作失败: ${e}`, false)
} finally {
setIsTogglingApi(false)
}
}
const handleCopyApiUrl = () => {
const url = `http://${httpApiHost}:${httpApiPort}`
navigator.clipboard.writeText(url)
showMessage('已复制 API 地址', true)
}
const handleToggleMessagePush = async (enabled: boolean) => {
setMessagePushEnabled(enabled)
await configService.setMessagePushEnabled(enabled)
showMessage(enabled ? '已开启主动推送' : '已关闭主动推送', true)
}
const handleTestInsightConnection = async () => {
setIsTestingInsight(true)
setInsightTestResult(null)
try {
const result = await (window.electronAPI as any).insight.testConnection()
setInsightTestResult(result)
} catch (e: any) {
setInsightTestResult({ success: false, message: `调用失败:${e?.message || String(e)}` })
} finally {
setIsTestingInsight(false)
}
}
const renderInsightTab = () => (
<div className="tab-content">
{/* 总开关 */}
<div className="form-group">
<label>AI </label>
<span className="form-hint">
AI
</span>
<div className="log-toggle-line">
<span className="log-status">{aiInsightEnabled ? '已开启' : '已关闭'}</span>
<label className="switch">
<input
type="checkbox"
checked={aiInsightEnabled}
onChange={async (e) => {
const val = e.target.checked
setAiInsightEnabled(val)
await configService.setAiInsightEnabled(val)
showMessage(val ? 'AI 见解已开启' : 'AI 见解已关闭', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="divider" />
{/* API 配置 */}
<div className="form-group">
<label>API </label>
<span className="form-hint">
OpenAI <strong>Base URL</strong><strong></strong>
<code>/chat/completions</code>
<br />
<code>https://api.ohmygpt.com/v1</code> 或 <code>https://api.openai.com/v1</code>
</span>
<input
type="text"
className="field-input"
value={aiInsightApiBaseUrl}
placeholder="https://api.ohmygpt.com/v1"
onChange={(e) => {
const val = e.target.value
setAiInsightApiBaseUrl(val)
scheduleConfigSave('aiInsightApiBaseUrl', () => configService.setAiInsightApiBaseUrl(val))
}}
style={{ fontFamily: 'monospace' }}
/>
</div>
<div className="form-group">
<label>API Key</label>
<span className="form-hint">
API Key
</span>
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
<input
type={showInsightApiKey ? 'text' : 'password'}
className="field-input"
value={aiInsightApiKey}
placeholder="sk-..."
onChange={(e) => {
const val = e.target.value
setAiInsightApiKey(val)
scheduleConfigSave('aiInsightApiKey', () => configService.setAiInsightApiKey(val))
}}
style={{ flex: 1, fontFamily: 'monospace' }}
/>
<button
className="btn btn-secondary"
onClick={() => setShowInsightApiKey(!showInsightApiKey)}
title={showInsightApiKey ? '隐藏' : '显示'}
>
{showInsightApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
{aiInsightApiKey && (
<button
className="btn btn-danger"
onClick={async () => {
setAiInsightApiKey('')
await configService.setAiInsightApiKey('')
}}
title="清除 Key"
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">
API 使
<br />
<code>gpt-4o-mini</code><code>gpt-4o</code><code>deepseek-chat</code><code>claude-3-5-haiku-20241022</code>
</span>
<input
type="text"
className="field-input"
value={aiInsightApiModel}
placeholder="gpt-4o-mini"
onChange={(e) => {
const val = e.target.value.trim() || 'gpt-4o-mini'
setAiInsightApiModel(val)
scheduleConfigSave('aiInsightApiModel', () => configService.setAiInsightApiModel(val))
}}
style={{ width: 260, fontFamily: 'monospace' }}
/>
</div>
{/* 测试连接 + 触发测试 */}
<div className="form-group">
<label></label>
<span className="form-hint">
"测试 API 连接" Key URL "立即触发测试见解"API
</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginTop: '10px' }}>
{/* 测试 API 连接 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
<button
className="btn btn-secondary"
onClick={handleTestInsightConnection}
disabled={isTestingInsight || !aiInsightApiBaseUrl || !aiInsightApiKey}
>
{isTestingInsight ? (
<><Loader2 size={14} style={{ marginRight: 4, animation: 'spin 1s linear infinite' }} />...</>
) : (
<> API </>
)}
</button>
{insightTestResult && (
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: insightTestResult.success ? 'var(--color-success, #22c55e)' : 'var(--color-danger, #ef4444)' }}>
{insightTestResult.success ? <CheckCircle2 size={14} /> : <XCircle size={14} />}
{insightTestResult.message}
</span>
)}
</div>
{/* 触发测试见解 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
<button
className="btn btn-secondary"
onClick={async () => {
setIsTriggeringInsightTest(true)
setInsightTriggerResult(null)
try {
const result = await (window.electronAPI as any).insight.triggerTest()
setInsightTriggerResult(result)
} catch (e: any) {
setInsightTriggerResult({ success: false, message: `调用失败:${e?.message || String(e)}` })
} finally {
setIsTriggeringInsightTest(false)
}
}}
disabled={isTriggeringInsightTest || !aiInsightEnabled || !aiInsightApiBaseUrl || !aiInsightApiKey}
title={!aiInsightEnabled ? '请先开启 AI 见解总开关' : ''}
>
{isTriggeringInsightTest ? (
<><Loader2 size={14} style={{ marginRight: 4, animation: 'spin 1s linear infinite' }} />...</>
) : (
<></>
)}
</button>
{insightTriggerResult && (
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: insightTriggerResult.success ? 'var(--color-success, #22c55e)' : 'var(--color-danger, #ef4444)' }}>
{insightTriggerResult.success ? <CheckCircle2 size={14} /> : <XCircle size={14} />}
{insightTriggerResult.message}
</span>
)}
</div>
</div>
</div>
<div className="divider" />
{/* 行为配置 */}
<div className="form-group">
<label></label>
<span className="form-hint">
<strong>0</strong> AI
</span>
<input
type="number"
className="field-input"
value={aiInsightCooldownMinutes}
min={0}
max={10080}
onChange={(e) => {
const val = Math.max(0, parseInt(e.target.value, 10) || 0)
setAiInsightCooldownMinutes(val)
scheduleConfigSave('aiInsightCooldownMinutes', () => configService.setAiInsightCooldownMinutes(val))
}}
style={{ width: 120 }}
/>
{aiInsightCooldownMinutes === 0 && (
<span style={{ marginLeft: 10, fontSize: 12, color: 'var(--color-warning, #f59e0b)' }}>
DB
</span>
)}
</div>
<div className="form-group">
<label></label>
<span className="form-hint">
0.1 6
</span>
<input
type="number"
className="field-input"
value={aiInsightScanIntervalHours}
min={0.1}
max={168}
step={0.5}
onChange={(e) => {
const val = Math.max(0.1, parseFloat(e.target.value) || 4)
setAiInsightScanIntervalHours(val)
scheduleConfigSave('aiInsightScanIntervalHours', () => configService.setAiInsightScanIntervalHours(val))
}}
style={{ width: 120 }}
/>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">
</span>
<input
type="number"
className="field-input"
value={aiInsightSilenceDays}
min={1}
max={365}
onChange={(e) => {
const val = Math.max(1, parseInt(e.target.value, 10) || 3)
setAiInsightSilenceDays(val)
scheduleConfigSave('aiInsightSilenceDays', () => configService.setAiInsightSilenceDays(val))
}}
style={{ width: 100 }}
/>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">
N AI
<br />
<strong></strong>AI
<br />
<strong></strong> API
</span>
<div className="log-toggle-line">
<span className="log-status">{aiInsightAllowContext ? '已授权' : '未授权'}</span>
<label className="switch">
<input
type="checkbox"
checked={aiInsightAllowContext}
onChange={async (e) => {
const val = e.target.checked
setAiInsightAllowContext(val)
await configService.setAiInsightAllowContext(val)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
{aiInsightAllowContext && (
<div className="form-group">
<label></label>
<span className="form-hint">
AI token
</span>
<input
type="number"
className="field-input"
value={aiInsightContextCount}
min={1}
max={200}
onChange={(e) => {
const val = Math.max(1, Math.min(200, parseInt(e.target.value, 10) || 40))
setAiInsightContextCount(val)
scheduleConfigSave('aiInsightContextCount', () => configService.setAiInsightContextCount(val))
}}
style={{ width: 100 }}
/>
</div>
)}
<div className="divider" />
{/* 自定义 System Prompt */}
{(() => {
const DEFAULT_SYSTEM_PROMPT = `你是用户的私人关系观察助手,名叫"见解"。你的任务是主动提供有价值的观察和建议。
要求:
1. 必须给出见解。基于聊天记录分析对方情绪、话题趋势、关系动态,或给出回复建议、聊天话题推荐。
2. 控制在 80 字以内,直接、具体、一针见血。不要废话。
3. 输出纯文本,不使用 Markdown。
4. 只有在完全没有任何可说的内容时(比如对话只有一条"嗯"),才回复"SKIP"。绝大多数情况下你应该输出见解。`
// 展示值:有自定义内容时显示自定义内容,否则显示默认值(可直接编辑)
const displayValue = aiInsightSystemPrompt || DEFAULT_SYSTEM_PROMPT
return (
<div className="form-group">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<label style={{ marginBottom: 0 }}> AI </label>
<button
className="button-secondary"
style={{ fontSize: 12, padding: '3px 10px' }}
onClick={async () => {
// 恢复默认清空自定义值UI 回到显示默认内容的状态
setAiInsightSystemPrompt('')
await configService.setAiInsightSystemPrompt('')
}}
>
</button>
</div>
<span className="form-hint">
</span>
<textarea
className="field-input"
rows={8}
style={{ width: '100%', resize: 'vertical', fontFamily: 'monospace', fontSize: 12 }}
value={displayValue}
onChange={(e) => {
const val = e.target.value
// 如果用户把内容改得和默认值一样,仍存自定义值(不影响功能)
setAiInsightSystemPrompt(val)
scheduleConfigSave('aiInsightSystemPrompt', () => configService.setAiInsightSystemPrompt(val))
}}
/>
</div>
)
})()}
<div className="divider" />
{/* Telegram 推送 */}
<div className="form-group">
<label>Telegram Bot </label>
<span className="form-hint">
Telegram /便 Bot Token @BotFatherChat ID @userinfobot ID
</span>
<div className="log-toggle-line">
<span className="log-status">{aiInsightTelegramEnabled ? '已启用' : '未启用'}</span>
<label className="switch">
<input
type="checkbox"
checked={aiInsightTelegramEnabled}
onChange={async (e) => {
const val = e.target.checked
setAiInsightTelegramEnabled(val)
await configService.setAiInsightTelegramEnabled(val)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
{aiInsightTelegramEnabled && (
<>
<div className="form-group">
<label>Bot Token</label>
<input
type="password"
className="field-input"
style={{ width: '100%' }}
placeholder="110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw"
value={aiInsightTelegramToken}
onChange={(e) => {
const val = e.target.value
setAiInsightTelegramToken(val)
scheduleConfigSave('aiInsightTelegramToken', () => configService.setAiInsightTelegramToken(val))
}}
/>
</div>
<div className="form-group">
<label>Chat ID</label>
<input
type="text"
className="field-input"
style={{ width: '100%' }}
placeholder="123456789, -987654321"
value={aiInsightTelegramChatIds}
onChange={(e) => {
const val = e.target.value
setAiInsightTelegramChatIds(val)
scheduleConfigSave('aiInsightTelegramChatIds', () => configService.setAiInsightTelegramChatIds(val))
}}
/>
</div>
</>
)}
<div className="divider" />
{/* 对话白名单 */}
{(() => {
const sortedSessions = [...chatSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
const keyword = insightWhitelistSearch.trim().toLowerCase()
const filteredSessions = sortedSessions.filter((s) => {
const id = s.username?.trim() || ''
if (!id || id.endsWith('@chatroom') || id.toLowerCase().includes('placeholder')) return false
if (!keyword) return true
return (
String(s.displayName || '').toLowerCase().includes(keyword) ||
id.toLowerCase().includes(keyword)
)
})
const filteredIds = filteredSessions.map((s) => s.username)
const selectedCount = aiInsightWhitelist.size
const selectedInFilteredCount = filteredIds.filter((id) => aiInsightWhitelist.has(id)).length
const allFilteredSelected = filteredIds.length > 0 && selectedInFilteredCount === filteredIds.length
const toggleSession = (id: string) => {
setAiInsightWhitelist((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const saveWhitelist = async (next: Set<string>) => {
await configService.setAiInsightWhitelist(Array.from(next))
}
const selectAllFiltered = () => {
setAiInsightWhitelist((prev) => {
const next = new Set(prev)
for (const id of filteredIds) next.add(id)
void saveWhitelist(next)
return next
})
}
const clearSelection = () => {
const next = new Set<string>()
setAiInsightWhitelist(next)
void saveWhitelist(next)
}
return (
<div className="anti-revoke-tab">
<div className="anti-revoke-hero">
<div className="anti-revoke-hero-main">
<h3></h3>
<p>
AI
</p>
</div>
<div className="anti-revoke-metrics">
<div className="anti-revoke-metric is-total">
<span className="label"></span>
<span className="value">{filteredIds.length + (keyword ? 0 : 0)}</span>
</div>
<div className="anti-revoke-metric is-installed">
<span className="label"></span>
<span className="value">{selectedCount}</span>
</div>
</div>
</div>
<div className="log-toggle-line" style={{ marginBottom: 12 }}>
<span className="log-status" style={{ fontWeight: 600 }}>
{aiInsightWhitelistEnabled ? '白名单已启用(仅对勾选对话生效)' : '白名单未启用(对所有私聊生效)'}
</span>
<label className="switch">
<input
type="checkbox"
checked={aiInsightWhitelistEnabled}
onChange={async (e) => {
const val = e.target.checked
setAiInsightWhitelistEnabled(val)
await configService.setAiInsightWhitelistEnabled(val)
}}
/>
<span className="switch-slider" />
</label>
</div>
<div className="anti-revoke-control-card">
<div className="anti-revoke-toolbar">
<div className="filter-search-box anti-revoke-search">
<Search size={14} />
<input
type="text"
placeholder="搜索私聊对话..."
value={insightWhitelistSearch}
onChange={(e) => setInsightWhitelistSearch(e.target.value)}
/>
</div>
<div className="anti-revoke-toolbar-actions">
<div className="anti-revoke-btn-group">
<button
className="btn btn-secondary btn-sm"
onClick={selectAllFiltered}
disabled={filteredIds.length === 0 || allFilteredSelected}
>
</button>
<button
className="btn btn-secondary btn-sm"
onClick={clearSelection}
disabled={selectedCount === 0}
>
</button>
</div>
</div>
</div>
<div className="anti-revoke-batch-actions">
<div className="anti-revoke-selected-count">
<span> <strong>{selectedCount}</strong> </span>
<span> <strong>{selectedInFilteredCount}</strong> / {filteredIds.length}</span>
</div>
</div>
</div>
<div className="anti-revoke-list">
{filteredSessions.length === 0 ? (
<div className="anti-revoke-empty">
{insightWhitelistSearch ? '没有匹配的对话' : '暂无私聊对话'}
</div>
) : (
<>
<div className="anti-revoke-list-header">
<span>{filteredSessions.length}</span>
<span></span>
</div>
{filteredSessions.map((session) => {
const isSelected = aiInsightWhitelist.has(session.username)
return (
<div
key={session.username}
className={`anti-revoke-row ${isSelected ? 'selected' : ''}`}
>
<label className="anti-revoke-row-main">
<span className="anti-revoke-check">
<input
type="checkbox"
checked={isSelected}
onChange={async () => {
setAiInsightWhitelist((prev) => {
const next = new Set(prev)
if (next.has(session.username)) next.delete(session.username)
else next.add(session.username)
void configService.setAiInsightWhitelist(Array.from(next))
return next
})
}}
/>
<span className="check-indicator" aria-hidden="true">
<Check size={12} />
</span>
</span>
<Avatar
src={session.avatarUrl}
name={session.displayName || session.username}
size={30}
/>
<div className="anti-revoke-row-text">
<span className="name">{session.displayName || session.username}</span>
</div>
</label>
<div className="anti-revoke-row-status">
<span className={`status-badge ${isSelected ? 'installed' : 'not-installed'}`}>
<i className="status-dot" aria-hidden="true" />
{isSelected ? '已加入' : '未加入'}
</span>
</div>
</div>
)
})}
</>
)}
</div>
</div>
)
})()}
<div className="divider" />
{/* 工作原理说明 */}
<div className="form-group">
<label></label>
<div className="api-docs">
<div className="api-item">
<p className="api-desc" style={{ lineHeight: 1.7 }}>
<strong></strong> 500ms <br />
<strong></strong> 4 <br />
<strong></strong> AI AI <br />
<strong></strong> API WeFlow
</p>
</div>
</div>
</div>
</div>
)
const renderApiTab = () => (
<div className="tab-content">
<div className="form-group">
<label>HTTP API </label>
<span className="form-hint"> HTTP 访</span>
<div className="log-toggle-line">
<span className="log-status">
{httpApiRunning ? '运行中' : '已停止'}
</span>
<label className="switch">
<input
type="checkbox"
checked={httpApiRunning}
onChange={handleToggleApi}
disabled={isTogglingApi}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">
API <code>127.0.0.1</code> 访Docker/N8N <code>0.0.0.0</code> 访 Token
</span>
<input
type="text"
className="field-input"
value={httpApiHost}
placeholder="127.0.0.1"
onChange={(e) => {
const host = e.target.value.trim() || '127.0.0.1'
setHttpApiHost(host)
scheduleConfigSave('httpApiHost', () => configService.setHttpApiHost(host))
}}
disabled={httpApiRunning}
style={{ width: 180, fontFamily: 'monospace' }}
/>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">API 1024-65535</span>
<input
type="number"
className="field-input"
value={httpApiPort}
onChange={(e) => {
const port = parseInt(e.target.value, 10) || 5031
setHttpApiPort(port)
scheduleConfigSave('httpApiPort', () => configService.setHttpApiPort(port))
}}
disabled={httpApiRunning}
style={{ width: 120 }}
min={1024}
max={65535}
/>
</div>
<div className="form-group">
<label>Access Token ()</label>
<span className="form-hint">
<code>Authorization: Bearer &lt;token&gt;</code>
<code>?access_token=&lt;token&gt;</code>
</span>
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
<input
type="text"
className="field-input"
value={httpApiToken}
placeholder="留空表示不验证 Token"
onChange={(e) => {
const val = e.target.value
setHttpApiToken(val)
scheduleConfigSave('httpApiToken', () => configService.setHttpApiToken(val))
}}
style={{ flex: 1, fontFamily: 'monospace' }}
/>
<button className="btn btn-secondary" onClick={generateRandomToken}>
<RefreshCw size={14} style={{ marginRight: 4 }} />
</button>
{httpApiToken && (
<button className="btn btn-danger" onClick={clearApiToken} title="清除 Token">
<Trash2 size={14} />
</button>
)}
</div>
</div>
{httpApiRunning && (
<div className="form-group">
<label>API </label>
<span className="form-hint">使访 API</span>
<div className="api-url-display">
<input
type="text"
className="field-input"
value={`http://${httpApiHost}:${httpApiPort}`}
readOnly
/>
<button className="btn btn-secondary" onClick={handleCopyApiUrl} title="复<><E5A48D><EFBFBD>">
<Copy size={16} />
</button>
</div>
</div>
)}
{/* API 安全警告弹窗 */}
<div className="form-group">
<label></label>
<span className="form-hint">`/api/v1/messages` `media=1` </span>
<input
type="text"
className="field-input"
value={httpApiMediaExportPath || '未获取到目录'}
readOnly
/>
</div>
<div className="divider" />
<div className="form-group">
<label></label>
<span className="form-hint"> API SSE </span>
<div className="log-toggle-line">
<span className="log-status">
{messagePushEnabled ? '已开启' : '已关闭'}
</span>
<label className="switch">
<input
type="checkbox"
checked={messagePushEnabled}
onChange={(e) => { void handleToggleMessagePush(e.target.checked) }}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"> SSE `HTTP API 服务`</span>
<div className="api-url-display">
<input
type="text"
className="field-input"
value={`http://${httpApiHost}:${httpApiPort}/api/v1/push/messages${httpApiToken ? `?access_token=${httpApiToken}` : ''}`}
readOnly
/>
<button
className="btn btn-secondary"
onClick={() => {
navigator.clipboard.writeText(`http://${httpApiHost}:${httpApiPort}/api/v1/push/messages${httpApiToken ? `?access_token=${httpApiToken}` : ''}`)
showMessage('已复制推送地址', true)
}}
title="复制"
>
<Copy size={16} />
</button>
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">SSE `message.new` `avatarUrl/sourceName/content` `groupName`</span>
<div className="api-docs">
<div className="api-item">
<div className="api-endpoint">
<span className="method get">GET</span>
<code>{`http://${httpApiHost}:${httpApiPort}/api/v1/push/messages`}</code>
</div>
<p className="api-desc"> SSE `messageKey` </p>
<div className="api-params">
{['event', 'sessionId', 'messageKey', 'avatarUrl', 'sourceName', 'groupName?', 'content'].map((param) => (
<span key={param} className="param">
<code>{param}</code>
</span>
))}
</div>
</div>
</div>
</div>
{showApiWarning && (
<div className="modal-overlay" onClick={() => setShowApiWarning(false)}>
<div className="api-warning-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<ShieldCheck size={20} />
<h3></h3>
</div>
<div className="modal-body">
<p className="warning-text"> HTTP API 访</p>
<div className="warning-list">
<div className="warning-item">
<span className="bullet"></span>
<span></span>
</div>
<div className="warning-item">
<span className="bullet"></span>
<span>使</span>
</div>
<div className="warning-item">
<span className="bullet"></span>
<span>使</span>
</div>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowApiWarning(false)}>
</button>
<button className="btn btn-primary" onClick={confirmStartApi}>
</button>
</div>
</div>
</div>
)}
</div>
)
const handleSetupHello = async () => {
if (!helloPassword) {
showMessage('请输入当前密码以开启 Hello', false)
return
}
if (!isWindows) {
showMessage('当前系统不支持 Windows Hello', false)
return
}
setIsSettingHello(true)
try {
const verifyResult = await window.electronAPI.auth.hello('请验证您的身份以开启 Windows Hello')
if (!verifyResult.success) {
showMessage(verifyResult.error || 'Windows Hello <20><>证失败', false)
return
}
const saveResult = await window.electronAPI.auth.setHelloSecret(helloPassword)
if (!saveResult.success) {
showMessage('Windows Hello 配置保存失败', false)
return
}
setAuthUseHello(true)
setHelloPassword('')
showMessage('Windows Hello 设置成功', true)
} catch (e: any) {
showMessage(`Windows Hello 设置失败: ${e?.message || String(e)}`, 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 = () => (
<div className="tab-content">
<div className="settings-section">
<h2></h2>
<div className="setting-item">
<div className="setting-label">
<span></span>
<span className="setting-desc"></span>
</div>
<div className="setting-control" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '8px' }}>
<textarea
className="form-input"
style={{ width: '100%', height: '200px', fontFamily: 'monospace' }}
value={excludeWordsInput}
onChange={(e) => setExcludeWordsInput(e.target.value)}
placeholder="例如:
第一个词
第二个词
第三个词"
/>
<div className="button-group">
<button
className="btn btn-primary"
onClick={async () => {
const words = excludeWordsInput.split('\n').map(w => w.trim()).filter(w => w.length > 0)
// 去重
const uniqueWords = Array.from(new Set(words))
await configService.setWordCloudExcludeWords(uniqueWords)
setWordCloudExcludeWords(uniqueWords)
setExcludeWordsInput(uniqueWords.join('\n'))
// Show success toast or feedback if needed (optional)
}}
>
</button>
<button
className="btn btn-secondary"
onClick={() => {
setExcludeWordsInput(wordCloudExcludeWords.join('\n'))
}}
>
</button>
</div>
</div>
</div>
</div>
</div>
)
const renderSecurityTab = () => (
<div className="tab-content">
<div className="form-group">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<label></label>
<span className="form-hint">{
isLockMode ? '已开启' :
authEnabled ? '旧版模式 — 请重新设置密码以升级为新模式提高安全性' :
'未开启 — 请设置密码以开启'
}</span>
</div>
{authEnabled && !showDisableLockInput && (
<button
className="btn btn-secondary btn-sm"
onClick={() => setShowDisableLockInput(true)}
>
</button>
)}
</div>
{showDisableLockInput && (
<div style={{ marginTop: 10, display: 'flex', gap: 10 }}>
<input
type="password"
className="field-input"
placeholder="输入当前密码以关闭"
value={disableLockPassword}
onChange={e => setDisableLockPassword(e.target.value)}
style={{ flex: 1 }}
/>
<button
className="btn btn-primary btn-sm"
disabled={!disableLockPassword}
onClick={async () => {
const result = await window.electronAPI.auth.disableLock(disableLockPassword)
if (result.success) {
setAuthEnabled(false)
setAuthUseHello(false)
setIsLockMode(false)
setShowDisableLockInput(false)
setDisableLockPassword('')
showMessage('应用锁已关闭', true)
} else {
showMessage(result.error || '关闭失败', false)
}
}}
></button>
<button
className="btn btn-secondary btn-sm"
onClick={() => { setShowDisableLockInput(false); setDisableLockPassword('') }}
></button>
</div>
)}
</div>
<div className="divider" />
<div className="form-group">
<label>{isLockMode ? '修改密码' : '设置密码并开启应用锁'}</label>
<span className="form-hint">{isLockMode ? '修改应用锁密码(需要旧密码验证)' : '设置密码后将自动开启应用锁'}</span>
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 10 }}>
{isLockMode && (
<input
type="password"
className="field-input"
placeholder="旧密码"
value={oldPassword}
onChange={e => setOldPassword(e.target.value)}
/>
)}
<input
type="password"
className="field-input"
placeholder="新密码"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
/>
<div style={{ display: 'flex', gap: 10 }}>
<input
type="password"
className="field-input"
placeholder="确认新密码"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
style={{ flex: 1 }}
/>
<button className="btn btn-primary" onClick={handleUpdatePassword} disabled={!newPassword}>
{isLockMode ? '更新' : '开启'}
</button>
</div>
</div>
</div>
<div className="divider" />
<div className="form-group">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<label>Windows Hello</label>
<span className="form-hint">使</span>
{!authEnabled && <div className="form-hint warning" style={{ color: '#ff4d4f' }}></div>}
{!helloAvailable && authEnabled && <div className="form-hint warning" style={{ color: '#ff4d4f' }}> Windows Hello</div>}
</div>
<div>
{authUseHello ? (
<button className="btn btn-secondary btn-sm" onClick={async () => {
await window.electronAPI.auth.clearHelloSecret()
setAuthUseHello(false)
showMessage('Windows Hello 已关闭', true)
}}></button>
) : (
<button
className="btn btn-secondary btn-sm"
onClick={handleSetupHello}
disabled={!helloAvailable || isSettingHello || !authEnabled || !helloPassword}
>
{isSettingHello ? '<27><><EFBFBD>置中...' : '开启与设置'}
</button>
)}
</div>
</div>
{!authUseHello && authEnabled && (
<div style={{ marginTop: 10 }}>
<input
type="password"
className="field-input"
placeholder="输入当前密码以开启 Hello"
value={helloPassword}
onChange={e => setHelloPassword(e.target.value)}
/>
</div>
)}
</div>
</div>
)
const renderAboutTab = () => (
<div className="tab-content about-tab">
<div className="about-card">
<div className="about-logo">
<img src="./logo.png" alt="WeFlow" />
</div>
<h2 className="about-name">WeFlow</h2>
<p className="about-version">v{appVersion || '...'}</p>
</div>
<div className="about-footer">
<p className="about-desc"></p>
<div className="about-links">
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.shell.openExternal('https://weflow.top') }}></a>
<span>·</span>
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.shell.openExternal('https://github.com/hicccc77/WeFlow') }}>GitHub </a>
<span>·</span>
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.shell.openExternal('https://chatlab.fun') }}>ChatLab</a>
<span>·</span>
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.window.openAgreementWindow() }}></a>
</div>
<p className="copyright">© 2026 WeFlow. All rights reserved.</p>
<div className="log-toggle-line" style={{ marginTop: '16px', justifyContent: 'center' }}>
<span style={{ fontSize: '13px', opacity: 0.7 }}></span>
<label className="switch">
<input
type="checkbox"
className="switch-input"
checked={analyticsConsent}
onChange={async (e) => {
const consent = e.target.checked
setAnalyticsConsent(consent)
await configService.setAnalyticsConsent(consent)
showMessage(consent ? '已允许数据收集' : '已拒绝数据收集', true)
}}
/>
<span className="switch-slider"></span>
</label>
</div>
</div>
</div>
)
const renderUpdatesTab = () => {
const downloadPercent = Math.max(0, Math.min(100, Number(downloadProgress?.percent || 0)))
const channelCards: { id: configService.UpdateChannel; title: string; desc: string }[] = [
{ id: 'stable', title: '稳定版', desc: '正式发布的版本,适合日常使用' },
{ id: 'preview', title: '预览版', desc: '正式发布前的预览体验版本' },
{ id: 'dev', title: '开发版', desc: '即刻体验我们的屎山代码' }
]
return (
<div className="tab-content updates-tab">
<div className="updates-hero">
<div className="updates-hero-main">
<span className="updates-chip"></span>
<h2>{appVersion || '...'}</h2>
<p>{updateInfo?.hasUpdate ? `发现新版本 v${updateInfo.version}` : '当前已是最新版本,可手动检查更新'}</p>
</div>
<div className="updates-hero-action">
{updateInfo?.hasUpdate ? (
<button className="btn btn-primary" onClick={() => setShowUpdateDialog(true)}>
<Download size={16} />
</button>
) : (
<button className="btn btn-secondary" onClick={handleCheckUpdate} disabled={isCheckingUpdate}>
<RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} />
{isCheckingUpdate ? '检查中...' : '检查更新'}
</button>
)}
</div>
</div>
{(isDownloading || updateInfo?.hasUpdate) && (
<div className="updates-progress-card">
<div className="updates-progress-header">
<h3>{isDownloading ? `正在下载 v${updateInfo?.version || ''}` : `新版本 v${updateInfo?.version} 已就绪`}</h3>
{isDownloading ? <strong>{downloadPercent.toFixed(0)}%</strong> : <span></span>}
</div>
<div className="updates-progress-track">
<div className="updates-progress-fill" style={{ width: `${isDownloading ? downloadPercent : 100}%` }} />
</div>
{updateInfo?.hasUpdate && !isDownloading && (
<button className="btn btn-secondary updates-ignore-btn" onClick={handleIgnoreUpdate}>
</button>
)}
</div>
)}
<div className="updates-card">
<div className="updates-card-header">
<h3></h3>
<span></span>
</div>
<div className="update-channel-grid">
{channelCards.map((channel) => {
const active = updateChannel === channel.id
return (
<button
key={channel.id}
className={`update-channel-card ${active ? 'active' : ''}`}
onClick={() => void handleUpdateChannelChange(channel.id)}
disabled={active}
>
<div className="update-channel-title-row">
<span className="title">{channel.title}</span>
{active && <Check size={16} />}
</div>
<span className="desc">{channel.desc}</span>
</button>
)
})}
</div>
</div>
</div>
)
}
return (
<div className={`settings-modal-overlay ${isClosing ? 'closing' : ''}`} onClick={handleClose}>
<div className={`settings-page ${isClosing ? 'closing' : ''}`} onClick={(event) => event.stopPropagation()}>
{message && <div className={`message-toast ${message.success ? 'success' : 'error'}`}>{message.text}</div>}
{/* 多账号选择对话框 */}
{showWxidSelect && wxidOptions.length > 1 && (
<div className="wxid-dialog-overlay" onClick={() => setShowWxidSelect(false)}>
<div className="wxid-dialog" onClick={(e) => e.stopPropagation()}>
<div className="wxid-dialog-header">
<h3></h3>
<p>使</p>
</div>
<div className="wxid-dialog-list">
{wxidOptions.map((opt) => (
<div
key={opt.wxid}
className={`wxid-dialog-item ${opt.wxid === wxid ? 'active' : ''}`}
onClick={() => handleSelectWxid(opt.wxid)}
>
<div className="wxid-profile-row">
{opt.avatarUrl ? (
<img src={opt.avatarUrl} alt="avatar" className="wxid-avatar" />
) : (
<div className="wxid-avatar-fallback"><UserRound size={18}/></div>
)}
<div className="wxid-info-col">
<span className="wxid-id">{opt.nickname || opt.wxid}</span>
{opt.nickname && <span className="wxid-date">{opt.wxid}</span>}
</div>
</div>
<span className="wxid-date" style={{marginLeft: 'auto'}}> {new Date(opt.modifiedTime).toLocaleString()}</span>
</div>
))}
</div>
<div className="wxid-dialog-footer">
<button className="btn btn-secondary" onClick={() => setShowWxidSelect(false)}></button>
</div>
</div>
</div>
)}
<div className="settings-header">
<div className="settings-title-block">
<h1></h1>
</div>
<div className="settings-actions">
{onClose && (
<button type="button" className="settings-close-btn" onClick={handleClose} aria-label="关闭设置">
<X size={18} />
</button>
)}
</div>
</div>
<div className="settings-layout">
<div className="settings-tabs" role="tablist" aria-label="设置项">
{tabs.map(tab => (
<button
key={tab.id}
className={`tab-btn ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
<tab.icon size={16} />
<span>{tab.label}</span>
</button>
))}
</div>
<div className="settings-body">
{activeTab === 'appearance' && renderAppearanceTab()}
{activeTab === 'notification' && renderNotificationTab()}
{activeTab === 'antiRevoke' && renderAntiRevokeTab()}
{activeTab === 'database' && renderDatabaseTab()}
{activeTab === 'models' && renderModelsTab()}
{activeTab === 'cache' && renderCacheTab()}
{activeTab === 'api' && renderApiTab()}
{activeTab === 'insight' && renderInsightTab()}
{activeTab === 'updates' && renderUpdatesTab()}
{activeTab === 'analytics' && renderAnalyticsTab()}
{activeTab === 'security' && renderSecurityTab()}
{activeTab === 'about' && renderAboutTab()}
</div>
</div>
</div>
</div>
)
}
export default SettingsPage