Files
CipherTalk/src/pages/SettingsPage.tsx
T

2813 lines
109 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 } from 'react'
import { useSearchParams, useLocation } from 'react-router-dom'
import { useAppStore } from '../stores/appStore'
import { useThemeStore, themes } from '../stores/themeStore'
import { useActivationStore } from '../stores/activationStore'
import { dialog } from '../services/ipc'
import * as configService from '../services/config'
import AISummarySettings from '../components/ai/AISummarySettings'
import {
Eye, EyeOff, Key, FolderSearch, FolderOpen, Search,
RotateCcw, Trash2, Save, Plug, X, Check, Sun, Moon, Monitor,
Palette, Database, ImageIcon, Download, HardDrive, Info, RefreshCw, Shield, Clock, CheckCircle, AlertCircle, Mic,
Zap, Layers, User, Sparkles, Github, Fingerprint, Lock, ShieldCheck, Minus, Plus, Smile
} from 'lucide-react'
import { useAuthStore } from '../stores/authStore'
import './SettingsPage.scss'
type SettingsTab = 'appearance' | 'database' | 'stt' | 'ai' | 'data' | 'security' | 'activation' | 'about'
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
{ id: 'appearance', label: '外观', icon: Palette },
{ id: 'database', label: '数据解密', icon: Database },
{ id: 'security', label: '安全设置', icon: Lock },
{ id: 'stt', label: '语音转文字', icon: Mic },
{ id: 'ai', label: 'AI 摘要', icon: Sparkles },
{ id: 'data', label: '数据管理', icon: HardDrive },
// { id: 'activation', label: '激活', icon: Shield },
{ id: 'about', label: '关于', icon: Info }
]
const sttLanguageOptions = [
{ value: 'zh', label: '中文', enLabel: 'Chinese' },
{ value: 'en', label: '英语', enLabel: 'English' },
{ value: 'ja', label: '日语', enLabel: 'Japanese' },
{ value: 'ko', label: '韩语', enLabel: 'Korean' },
{ value: 'yue', label: '粤语', enLabel: 'Cantonese' }
]
const sttModelTypeOptions = [
{ value: 'int8', label: 'int8 量化版', size: '235 MB', desc: '推荐,体积小、速度快' },
{ value: 'float32', label: 'float32 完整版', size: '920 MB', desc: '更高精度,体积较大' }
]
function SettingsPage() {
const [searchParams] = useSearchParams()
const location = useLocation()
const { setDbConnected, setLoading } = useAppStore()
const { currentTheme, themeMode, setTheme, setThemeMode, appIcon, setAppIcon } = useThemeStore()
const { status: activationStatus, checkStatus: checkActivationStatus } = useActivationStore()
const { isAuthEnabled, enableAuth, disableAuth, setupPassword, authMethod } = useAuthStore()
const [passwordInput, setPasswordInput] = useState('')
const [showPasswordInput, setShowPasswordInput] = useState(false)
// 安全设置确认弹窗状态
const [securityConfirm, setSecurityConfirm] = useState<{
show: boolean;
title: string;
message: string;
onConfirm: () => void;
}>({ show: false, title: '', message: '', onConfirm: () => { } })
const [activeTab, setActiveTab] = useState<SettingsTab>(() => {
const tab = searchParams.get('tab')
if (tab && tabs.some(t => t.id === tab)) {
return tab as SettingsTab
}
return 'appearance'
})
// 切换到激活 tab 时自动刷新状态
useEffect(() => {
if (activeTab === 'activation') {
checkActivationStatus()
}
}, [activeTab])
const [decryptKey, setDecryptKey] = useState('')
const [dbPath, setDbPath] = useState('')
const [wxid, setWxid] = useState('')
const [wxidOptions, setWxidOptions] = useState<string[]>([])
const [showWxidDropdown, setShowWxidDropdown] = useState(false)
const [isScanningWxid, setIsScanningWxid] = useState(false)
const [isAccountVerified, setIsAccountVerified] = useState(false)
const [isVerifyingAccount, setIsVerifyingAccount] = useState(false)
const [cachePath, setCachePath] = useState('')
const [imageXorKey, setImageXorKey] = useState('')
const [imageAesKey, setImageAesKey] = useState('')
const [exportPath, setExportPath] = useState('')
const [defaultExportPath, setDefaultExportPath] = useState('')
const [isLoading, setIsLoadingState] = useState(false)
const [isTesting, setIsTesting] = useState(false)
const [isGettingKey, setIsGettingKey] = useState(false)
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
const [isDownloading, setIsDownloading] = useState(false)
const [downloadProgress, setDownloadProgress] = useState(0)
const [appVersion, setAppVersion] = useState('')
const [updateInfo, setUpdateInfo] = useState<{ hasUpdate: boolean; version?: string; releaseNotes?: string } | null>(null)
const [keyStatus, setKeyStatus] = useState('')
const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null)
const [showDecryptKey, setShowDecryptKey] = useState(false)
const [showXorKey, setShowXorKey] = useState(false)
const [closeToTray, setCloseToTray] = useState(true)
const [showAesKey, setShowAesKey] = useState(false)
const [showClearDialog, setShowClearDialog] = useState<{
type: 'images' | 'emojis' | 'databases' | 'all' | 'config'
title: string
message: string
} | null>(null)
const [cacheSize, setCacheSize] = useState<{
images: number
emojis: number
databases: number
logs: number
total: number
} | null>(null)
const [isLoadingCacheSize, setIsLoadingCacheSize] = useState(false)
const [sttLanguages, setSttLanguagesState] = useState<string[]>([])
const [sttModelType, setSttModelType] = useState<'int8' | 'float32'>('int8')
const [quoteStyle, setQuoteStyle] = useState<'default' | 'wechat'>('default')
const [skipIntegrityCheck, setSkipIntegrityCheck] = useState(false)
const [exportDefaultDateRange, setExportDefaultDateRange] = useState<number>(0)
const [exportDefaultAvatars, setExportDefaultAvatars] = useState<boolean>(true)
const [autoUpdateDatabase, setAutoUpdateDatabase] = useState(true)
// 自动同步高级参数
const [autoUpdateCheckInterval, setAutoUpdateCheckInterval] = useState(60) // 检查间隔(秒)
const [autoUpdateMinInterval, setAutoUpdateMinInterval] = useState(1000) // 最小更新间隔(毫秒)
const [autoUpdateDebounceTime, setAutoUpdateDebounceTime] = useState(500) // 防抖时间(毫秒)
// AI 相关配置状态
const [aiProvider, setAiProviderState] = useState('zhipu')
const [aiApiKey, setAiApiKeyState] = useState('')
const [aiModel, setAiModelState] = useState('')
const [aiDefaultTimeRange, setAiDefaultTimeRangeState] = useState<number>(7)
const [aiSummaryDetail, setAiSummaryDetailState] = useState<'simple' | 'normal' | 'detailed'>('normal')
const [aiSystemPromptPreset, setAiSystemPromptPresetState] = useState<'default' | 'decision-focus' | 'action-focus' | 'risk-focus' | 'custom'>('default')
const [aiCustomSystemPrompt, setAiCustomSystemPromptState] = useState<string>('')
const [aiEnableThinking, setAiEnableThinkingState] = useState<boolean>(true)
const [aiMessageLimit, setAiMessageLimitState] = useState<number>(3000)
// 日志相关状态
const [logFiles, setLogFiles] = useState<Array<{ name: string; size: number; mtime: Date }>>([])
const [selectedLogFile, setSelectedLogFile] = useState<string>('')
const [logContent, setLogContent] = useState<string>('')
const [isLoadingLogs, setIsLoadingLogs] = useState(false)
const [isLoadingLogContent, setIsLoadingLogContent] = useState(false)
const [logSize, setLogSize] = useState<number>(0)
const [currentLogLevel, setCurrentLogLevel] = useState<string>('WARN')
// 配置变化状态
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const [initialConfig, setInitialConfig] = useState<any>(null)
useEffect(() => {
loadConfig()
loadDefaultExportPath()
loadAppVersion()
loadCacheSize()
loadLogFiles()
}, [])
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 savedXorKey = await configService.getImageXorKey()
const savedAesKey = await configService.getImageAesKey()
const savedExportPath = await configService.getExportPath()
const savedSttLanguages = await configService.getSttLanguages()
const savedSttModelType = await configService.getSttModelType()
const savedSkipIntegrityCheck = await configService.getSkipIntegrityCheck()
const savedAutoUpdateDatabase = await configService.getAutoUpdateDatabase()
if (savedKey) setDecryptKey(savedKey)
if (savedPath) setDbPath(savedPath)
if (savedWxid) setWxid(savedWxid)
if (savedCachePath) setCachePath(savedCachePath)
if (savedXorKey) setImageXorKey(savedXorKey)
if (savedAesKey) setImageAesKey(savedAesKey)
if (savedExportPath) setExportPath(savedExportPath)
if (savedSttLanguages && savedSttLanguages.length > 0) {
setSttLanguagesState(savedSttLanguages)
} else {
setSttLanguagesState(['zh'])
}
setSttModelType(savedSttModelType)
setSkipIntegrityCheck(savedSkipIntegrityCheck)
setAutoUpdateDatabase(savedAutoUpdateDatabase)
// 加载自动同步高级参数
const savedCheckInterval = await configService.getAutoUpdateCheckInterval()
const savedMinInterval = await configService.getAutoUpdateMinInterval()
const savedDebounceTime = await configService.getAutoUpdateDebounceTime()
setAutoUpdateCheckInterval(savedCheckInterval)
setAutoUpdateMinInterval(savedMinInterval)
setAutoUpdateDebounceTime(savedDebounceTime)
const savedQuoteStyle = await configService.getQuoteStyle()
setQuoteStyle(savedQuoteStyle)
const savedExportDefaultDateRange = await configService.getExportDefaultDateRange()
setExportDefaultDateRange(savedExportDefaultDateRange)
const savedExportDefaultAvatars = await configService.getExportDefaultAvatars()
setExportDefaultAvatars(savedExportDefaultAvatars)
// 加载 AI 配置
const savedAiProvider = await configService.getAiProvider()
const savedAiApiKey = await configService.getAiApiKey()
const savedAiModel = await configService.getAiModel()
const savedAiDefaultTimeRange = await configService.getAiDefaultTimeRange()
const savedAiSummaryDetail = await configService.getAiSummaryDetail()
const savedAiSystemPromptPreset = await configService.getAiSystemPromptPreset()
const savedAiCustomSystemPrompt = await configService.getAiCustomSystemPrompt()
const savedAiEnableThinking = await configService.getAiEnableThinking()
const savedAiMessageLimit = await configService.getAiMessageLimit()
setAiProviderState(savedAiProvider)
setAiApiKeyState(savedAiApiKey)
setAiModelState(savedAiModel)
setAiDefaultTimeRangeState(savedAiDefaultTimeRange)
setAiSummaryDetailState(savedAiSummaryDetail)
setAiSystemPromptPresetState(savedAiSystemPromptPreset)
setAiCustomSystemPromptState(savedAiCustomSystemPrompt)
setAiEnableThinkingState(savedAiEnableThinking)
setAiMessageLimitState(savedAiMessageLimit)
// 加载关闭行为配置
const savedCloseToTray = await configService.getCloseToTray()
setCloseToTray(savedCloseToTray)
// 保存初始配置用于比较
setInitialConfig({
decryptKey: savedKey || '',
dbPath: savedPath || '',
wxid: savedWxid || '',
cachePath: savedCachePath || '',
imageXorKey: savedXorKey || '',
imageAesKey: savedAesKey || '',
exportPath: savedExportPath || '',
sttLanguages: savedSttLanguages && savedSttLanguages.length > 0 ? savedSttLanguages : ['zh'],
sttModelType: savedSttModelType,
skipIntegrityCheck: savedSkipIntegrityCheck,
autoUpdateDatabase: savedAutoUpdateDatabase,
autoUpdateCheckInterval: savedCheckInterval,
autoUpdateMinInterval: savedMinInterval,
autoUpdateDebounceTime: savedDebounceTime,
quoteStyle: savedQuoteStyle,
exportDefaultDateRange: savedExportDefaultDateRange,
exportDefaultAvatars: savedExportDefaultAvatars,
aiProvider: savedAiProvider,
aiApiKey: savedAiApiKey,
aiModel: savedAiModel,
aiDefaultTimeRange: savedAiDefaultTimeRange,
aiSummaryDetail: savedAiSummaryDetail,
aiSystemPromptPreset: savedAiSystemPromptPreset,
aiCustomSystemPrompt: savedAiCustomSystemPrompt,
aiEnableThinking: savedAiEnableThinking,
aiMessageLimit: savedAiMessageLimit,
closeToTray: savedCloseToTray
})
} catch (e) {
console.error('加载配置失败:', e)
}
}
const loadDefaultExportPath = async () => {
try {
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
setDefaultExportPath(downloadsPath)
} catch (e) {
console.error('获取默认导出路径失败:', e)
}
}
// 监听配置变化
useEffect(() => {
if (!initialConfig) return
const currentConfig = {
decryptKey,
dbPath,
wxid,
cachePath,
imageXorKey,
imageAesKey,
exportPath,
sttLanguages,
sttModelType,
skipIntegrityCheck,
autoUpdateDatabase,
autoUpdateCheckInterval,
autoUpdateMinInterval,
autoUpdateDebounceTime,
quoteStyle,
exportDefaultDateRange,
exportDefaultAvatars,
aiProvider,
aiApiKey,
aiModel,
aiDefaultTimeRange,
aiSummaryDetail,
aiSystemPromptPreset,
aiCustomSystemPrompt,
aiEnableThinking,
aiMessageLimit,
closeToTray
}
// 深度比较配置是否有变化
const hasChanges = JSON.stringify(currentConfig) !== JSON.stringify(initialConfig)
setHasUnsavedChanges(hasChanges)
}, [
decryptKey, dbPath, wxid, cachePath, imageXorKey, imageAesKey, exportPath,
sttLanguages, sttModelType, skipIntegrityCheck, autoUpdateDatabase,
autoUpdateCheckInterval, autoUpdateMinInterval, autoUpdateDebounceTime,
quoteStyle, exportDefaultDateRange, exportDefaultAvatars,
aiProvider, aiApiKey, aiModel, aiDefaultTimeRange, aiSummaryDetail,
aiSystemPromptPreset, aiCustomSystemPrompt, aiEnableThinking, aiMessageLimit,
closeToTray, initialConfig
])
const loadAppVersion = async () => {
try {
const version = await window.electronAPI.app.getVersion()
setAppVersion(version)
} catch (e) {
console.error('获取版本号失败:', e)
}
}
const loadCacheSize = async () => {
setIsLoadingCacheSize(true)
try {
const result = await window.electronAPI.cache.getCacheSize()
if (result.success && result.size) {
setCacheSize(result.size)
}
} catch (e) {
console.error('获取缓存大小失败:', e)
} finally {
setIsLoadingCacheSize(false)
}
}
const loadLogFiles = async () => {
setIsLoadingLogs(true)
try {
const [filesResult, sizeResult, levelResult] = await Promise.all([
window.electronAPI.log.getLogFiles(),
window.electronAPI.log.getLogSize(),
window.electronAPI.log.getLogLevel()
])
if (filesResult.success && filesResult.files) {
setLogFiles(filesResult.files)
}
if (sizeResult.success && sizeResult.size !== undefined) {
setLogSize(sizeResult.size)
}
if (levelResult.success && levelResult.level) {
setCurrentLogLevel(levelResult.level)
}
} catch (e) {
console.error('获取日志文件失败:', e)
} finally {
setIsLoadingLogs(false)
}
}
const loadLogContent = async (filename: string) => {
if (!filename) return
setIsLoadingLogContent(true)
try {
const result = await window.electronAPI.log.readLogFile(filename)
if (result.success && result.content) {
setLogContent(result.content)
} else {
setLogContent('无法读取日志文件')
}
} catch (e) {
console.error('读取日志文件失败:', e)
setLogContent('读取日志文件失败')
} finally {
setIsLoadingLogContent(false)
}
}
const handleClearLogs = async () => {
try {
const result = await window.electronAPI.log.clearLogs()
if (result.success) {
showMessage('日志清除成功', true)
setLogFiles([])
setLogContent('')
setSelectedLogFile('')
setLogSize(0)
await loadCacheSize() // 重新加载缓存大小
} else {
showMessage(result.error || '日志清除失败', false)
}
} catch (e) {
showMessage(`日志清除失败: ${e}`, false)
}
}
const handleLogFileSelect = (filename: string) => {
setSelectedLogFile(filename)
loadLogContent(filename)
}
const handleOpenLogDirectory = async () => {
try {
const result = await window.electronAPI.log.getLogDirectory()
if (result.success && result.directory) {
await window.electronAPI.shell.openPath(result.directory)
}
} catch (e) {
showMessage('打开日志目录失败', false)
}
}
const handleLogLevelChange = async (level: string) => {
try {
const result = await window.electronAPI.log.setLogLevel(level)
if (result.success) {
setCurrentLogLevel(level)
showMessage(`日志级别已设置为 ${level}`, true)
} else {
showMessage(result.error || '设置日志级别失败', false)
}
} catch (e) {
showMessage('设置日志级别失败', false)
}
}
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
}
// 监听下载进度
useEffect(() => {
const removeListener = window.electronAPI.app.onDownloadProgress?.((progress: number) => {
setDownloadProgress(progress)
})
return () => removeListener?.()
}, [])
const handleCheckUpdate = async () => {
setIsCheckingUpdate(true)
setUpdateInfo(null)
try {
const result = await window.electronAPI.app.checkForUpdates()
if (result.hasUpdate) {
setUpdateInfo(result)
showMessage(`发现新版本 ${result.version}`, true)
} else {
showMessage('当前已是最新版本', true)
}
} catch (e) {
showMessage(`检查更新失败: ${e}`, false)
} finally {
setIsCheckingUpdate(false)
}
}
const showMessage = (text: string, success: boolean) => {
setMessage({ text, success })
setTimeout(() => setMessage(null), 3000)
}
const handleClearImages = () => {
setShowClearDialog({
type: 'images',
title: '清除图片',
message: '此操作将删除所有解密后的图片文件,清除后无法恢复。确定要继续吗?'
})
}
const handleClearAllCache = () => {
setShowClearDialog({
type: 'all',
title: '清除所有',
message: '此操作将删除所有缓存数据(包括解密后的图片、表情包、数据库文件),清除后无法恢复。确定要继续吗?'
})
}
const handleClearEmojis = () => {
setShowClearDialog({
type: 'emojis',
title: '清除表情包',
message: '此操作将删除所有解密后的表情包缓存文件,清除后无法恢复。确定要继续吗?'
})
}
const handleClearDatabases = () => {
setShowClearDialog({
type: 'databases',
title: '清除数据库',
message: '此操作将删除所有解密后的数据库缓存文件,清除后需要重新解密数据库才能使用聊天记录。确定要继续吗?'
})
}
const handleClearConfig = () => {
setShowClearDialog({
type: 'config',
title: '清除配置',
message: '此操作将删除所有保存的配置信息(包括密钥、路径等),清除后无法恢复。确定要继续吗?'
})
}
const confirmClear = async () => {
if (!showClearDialog) return
try {
let result
switch (showClearDialog.type) {
case 'images':
result = await window.electronAPI.cache.clearImages()
break
case 'emojis':
result = await window.electronAPI.cache.clearEmojis()
break
case 'databases':
result = await window.electronAPI.cache.clearDatabases()
break
case 'all':
result = await window.electronAPI.cache.clearAll()
break
case 'config':
result = await window.electronAPI.cache.clearConfig()
break
}
if (result.success) {
showMessage(`${showClearDialog.title}成功`, true)
if (showClearDialog.type === 'config') {
await loadConfig()
} else {
await loadCacheSize()
}
} else {
showMessage(result.error || `${showClearDialog.title}失败`, false)
}
} catch (e) {
showMessage(`${showClearDialog.title}失败: ${e}`, false)
} finally {
setShowClearDialog(null)
}
}
const handleUpdateNow = async () => {
setIsDownloading(true)
setDownloadProgress(0)
try {
showMessage('正在下载更新...', true)
await window.electronAPI.app.downloadAndInstall()
} catch (e) {
showMessage(`更新失败: ${e}`, false)
setIsDownloading(false)
}
}
const handleGetKey = async () => {
if (isGettingKey) return
setIsGettingKey(true)
setKeyStatus('正在检查微信进程...')
try {
const isRunning = await window.electronAPI.wxKey.isWeChatRunning()
if (isRunning) {
const shouldKill = window.confirm('检测到微信正在运行,需要重启微信才能获取密钥。\n是否关闭当前微信?')
if (!shouldKill) {
setKeyStatus('已取消')
setIsGettingKey(false)
return
}
setKeyStatus('正在关闭微信...')
await window.electronAPI.wxKey.killWeChat()
await new Promise(resolve => setTimeout(resolve, 2000))
}
setKeyStatus('正在启动微信...')
const launched = await window.electronAPI.wxKey.launchWeChat()
if (!launched) {
showMessage('微信启动失败,请检查安装路径', false)
setKeyStatus('')
setIsGettingKey(false)
return
}
setKeyStatus('等待微信窗口加载...')
const windowReady = await window.electronAPI.wxKey.waitForWindow(15)
if (!windowReady) {
showMessage('等待微信窗口超时', false)
setKeyStatus('')
setIsGettingKey(false)
return
}
const removeListener = window.electronAPI.wxKey.onStatus(({ status }) => {
setKeyStatus(status)
})
setKeyStatus('Hook 已安装,请登录微信...')
const result = await window.electronAPI.wxKey.startGetKey()
removeListener()
if (result.success && result.key) {
setDecryptKey(result.key)
await configService.setDecryptKey(result.key)
// 自动检测当前登录的微信账号
setKeyStatus('正在检测当前登录账号...')
// 先尝试较短的时间范围(刚登录的情况)
let accountInfo = await window.electronAPI.wxKey.detectCurrentAccount(dbPath, 10) // 10分钟
// 如果没找到,尝试更长的时间范围
if (!accountInfo) {
accountInfo = await window.electronAPI.wxKey.detectCurrentAccount(dbPath, 60) // 1小时
}
if (accountInfo) {
setWxid(accountInfo.wxid)
await configService.setMyWxid(accountInfo.wxid)
showMessage(`密钥获取成功!已自动绑定账号: ${accountInfo.wxid}`, true)
} else {
showMessage('密钥获取成功,已自动保存!(未能自动检测账号,请手动输入 wxid)', true)
}
setKeyStatus('')
} else {
showMessage(result.error || '获取密钥失败', false)
setKeyStatus('')
}
} catch (e) {
showMessage(`获取密钥失败: ${e}`, false)
setKeyStatus('')
} finally {
setIsGettingKey(false)
}
}
const handleCancelGetKey = async () => {
await window.electronAPI.wxKey.cancel()
setIsGettingKey(false)
setKeyStatus('')
}
const handleOpenWelcomeWindow = async () => {
try {
await window.electronAPI.window.openWelcomeWindow()
} catch (e) {
showMessage('打开引导窗口失败', false)
}
}
const handleSelectDbPath = async () => {
try {
const result = await dialog.openFile({ title: '选择微信数据库根目录', properties: ['openDirectory'] })
if (!result.canceled && result.filePaths.length > 0) {
setDbPath(result.filePaths[0])
showMessage('已选择数据库目录', true)
}
} catch (e) {
showMessage('选择目录失败', false)
}
}
const handleSelectCachePath = async () => {
try {
const result = await dialog.openFile({ title: '选择缓存目录', properties: ['openDirectory'] })
if (!result.canceled && result.filePaths.length > 0) {
setCachePath(result.filePaths[0])
showMessage('已选择缓存目录', true)
}
} catch (e) {
showMessage('选择目录失败', false)
}
}
const handleSelectExportPath = async () => {
try {
const result = await dialog.openFile({ title: '选择导出目录', properties: ['openDirectory'] })
if (!result.canceled && result.filePaths.length > 0) {
setExportPath(result.filePaths[0])
await configService.setExportPath(result.filePaths[0])
showMessage('已设置导出目录', true)
}
} catch (e) {
showMessage('选择目录失败', false)
}
}
const handleResetExportPath = async () => {
try {
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
setExportPath(downloadsPath)
await configService.setExportPath(downloadsPath)
showMessage('已恢复为下载目录', true)
} catch (e) {
showMessage('恢复默认失败', false)
}
}
// 扫描 wxid
const handleScanWxid = async () => {
if (!dbPath) {
showMessage('请先配置数据库路径', false)
return
}
if (isScanningWxid) return
setIsScanningWxid(true)
try {
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
setIsAccountVerified(false)
if (wxids.length === 0) {
showMessage('未检测到账号目录(需包含 db_storage 文件夹)', false)
setWxidOptions([])
} else if (wxids.length === 1) {
// 只有一个账号,直接设置
setWxid(wxids[0])
showMessage(`已检测到候选账号目录:${wxids[0]}(待验证)`, true)
setWxidOptions([])
setShowWxidDropdown(false)
} else {
// 多个账号,显示选择下拉框
setWxidOptions(wxids)
setShowWxidDropdown(true)
showMessage(`检测到 ${wxids.length} 个候选账号目录,请选择后验证`, true)
}
} catch (e) {
showMessage(`扫描失败: ${e}`, false)
} finally {
setIsScanningWxid(false)
}
}
// 选择 wxid
const handleSelectWxid = async (selectedWxid: string) => {
setWxid(selectedWxid)
setIsAccountVerified(false)
setShowWxidDropdown(false)
showMessage(`已选择候选账号目录:${selectedWxid}(待验证)`, true)
}
const handleVerifyAccountDirectory = async () => {
if (!dbPath) { showMessage('请先选择数据库目录', false); return }
if (!decryptKey || decryptKey.length !== 64) { showMessage('请先配置64位解密密钥', false); return }
if (!wxid) { showMessage('请先选择账号目录', false); return }
setIsVerifyingAccount(true)
try {
const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid)
if (result.success) {
setIsAccountVerified(true)
await configService.setMyWxid(wxid)
showMessage(`账号目录验证成功:${wxid}`, true)
} else {
setIsAccountVerified(false)
showMessage(result.error || '账号目录验证失败,请更换目录重试', false)
}
} catch (e) {
setIsAccountVerified(false)
showMessage(`账号目录验证失败: ${e}`, false)
} finally {
setIsVerifyingAccount(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('请先选择账号目录', false); return }
if (!isAccountVerified) { showMessage('请先验证账号目录', 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) {
showMessage(`连接测试失败: ${e}`, false)
} finally {
setIsTesting(false)
}
}
const handleSaveConfig = async () => {
setIsLoadingState(true)
setLoading(true, '正在保存配置...')
try {
// 保存数据库相关配置
if (decryptKey) await configService.setDecryptKey(decryptKey)
if (dbPath) await configService.setDbPath(dbPath)
if (wxid) await configService.setMyWxid(wxid)
await configService.setCachePath(cachePath)
// 保存图片密钥(包括空值)
await configService.setImageXorKey(imageXorKey)
await configService.setImageAesKey(imageAesKey)
// 保存导出路径
if (exportPath) await configService.setExportPath(exportPath)
// 保存完整性检查设置
await configService.setSkipIntegrityCheck(skipIntegrityCheck)
// 保存自动更新设置
await configService.setAutoUpdateDatabase(autoUpdateDatabase)
// 保存自动同步高级参数
await configService.setAutoUpdateCheckInterval(autoUpdateCheckInterval)
await configService.setAutoUpdateMinInterval(autoUpdateMinInterval)
await configService.setAutoUpdateDebounceTime(autoUpdateDebounceTime)
// 保存引用样式
await configService.setQuoteStyle(quoteStyle)
// 保存导出默认设置
await configService.setExportDefaultDateRange(exportDefaultDateRange)
await configService.setExportDefaultAvatars(exportDefaultAvatars)
// 保存 AI 配置
await configService.setAiProvider(aiProvider)
await configService.setAiApiKey(aiApiKey)
await configService.setAiModel(aiModel)
await configService.setAiDefaultTimeRange(aiDefaultTimeRange)
await configService.setAiSummaryDetail(aiSummaryDetail)
await configService.setAiSystemPromptPreset(aiSystemPromptPreset)
await configService.setAiCustomSystemPrompt(aiCustomSystemPrompt)
await configService.setAiEnableThinking(aiEnableThinking)
await configService.setAiMessageLimit(aiMessageLimit)
// 保存关闭行为配置
await configService.setCloseToTray(closeToTray)
// 如果数据库配置完整,尝试设置已连接状态(不进行耗时测试,仅标记)
if (decryptKey && dbPath && wxid && decryptKey.length === 64 && isAccountVerified) {
setDbConnected(true, dbPath)
}
showMessage('配置保存成功', true)
// 保存成功后更新初始配置,重置变化状态
setInitialConfig({
decryptKey,
dbPath,
wxid,
cachePath,
imageXorKey,
imageAesKey,
exportPath,
sttLanguages,
sttModelType,
skipIntegrityCheck,
autoUpdateDatabase,
autoUpdateCheckInterval,
autoUpdateMinInterval,
autoUpdateDebounceTime,
quoteStyle,
exportDefaultDateRange,
exportDefaultAvatars,
aiProvider,
aiApiKey,
aiModel,
aiDefaultTimeRange,
aiSummaryDetail,
aiSystemPromptPreset,
aiCustomSystemPrompt,
aiEnableThinking,
aiMessageLimit,
closeToTray
})
setHasUnsavedChanges(false)
} catch (e) {
showMessage(`保存配置失败: ${e}`, false)
} finally {
setIsLoadingState(false)
setLoading(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: themeMode === 'dark' ? 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)' : `linear-gradient(135deg, ${theme.bgColor} 0%, ${theme.bgColor}dd 100%)` }}>
<div className="theme-accent" style={{ background: 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>
<h3 className="section-title" style={{ marginTop: '2rem' }}></h3>
<div className="quote-style-options">
<label className={`radio-label ${appIcon === 'default' ? 'active' : ''}`} style={{ width: 'auto', minWidth: '120px' }}>
<input
type="radio"
name="appIcon"
value="default"
checked={appIcon === 'default' || !appIcon}
onChange={() => setAppIcon('default')}
/>
<div className="radio-content" style={{ justifyContent: 'center' }}>
<div className="style-preview" style={{ justifyContent: 'center', padding: '10px' }}>
<img src="./logo.png" alt="默认" style={{ width: '48px', height: '48px' }} />
</div>
</div>
</label>
<label className={`radio-label ${appIcon === 'xinnian' ? 'active' : ''}`} style={{ width: 'auto', minWidth: '120px' }}>
<input
type="radio"
name="appIcon"
value="xinnian"
checked={appIcon === 'xinnian'}
onChange={() => setAppIcon('xinnian')}
/>
<div className="radio-content" style={{ justifyContent: 'center' }}>
<div className="style-preview" style={{ justifyContent: 'center', padding: '10px' }}>
<img src="./xinnian.png" alt="新年" style={{ width: '48px', height: '48px' }} />
</div>
</div>
</label>
</div>
<h3 className="section-title" style={{ marginTop: '2rem' }}></h3>
<div className="quote-style-options">
<label className={`radio-label ${quoteStyle === 'default' ? 'active' : ''}`}>
<input
type="radio"
name="quoteStyle"
value="default"
checked={quoteStyle === 'default'}
onChange={() => setQuoteStyle('default')}
/>
<div className="radio-content">
<div className="style-preview">
<img src="./logo.png" className="preview-avatar" alt="对方" />
<div className="preview-bubble default">
<div className="preview-quote">
张三: 那天去爬山的照片...
</div>
<div className="preview-text">
</div>
</div>
</div>
</div>
</label>
<label className={`radio-label ${quoteStyle === 'wechat' ? 'active' : ''}`}>
<input
type="radio"
name="quoteStyle"
value="wechat"
checked={quoteStyle === 'wechat'}
onChange={() => setQuoteStyle('wechat')}
/>
<div className="radio-content">
<div className="style-preview">
<img src="./logo.png" className="preview-avatar" alt="对方" />
<div className="preview-group">
<div className="preview-bubble wechat">
</div>
<div className="preview-quote-bubble">
张三: 那天去爬山的照片...
</div>
</div>
</div>
</div>
</label>
</div>
<h3 className="section-title" style={{ marginTop: '2rem' }}></h3>
<div className="quote-style-options">
<label className={`radio-label ${closeToTray ? 'active' : ''}`}>
<input
type="radio"
name="closeAction"
value="tray"
checked={closeToTray}
onChange={() => setCloseToTray(true)}
/>
<div className="radio-content">
<span className="radio-title"></span>
<span className="radio-desc"></span>
</div>
</label>
<label className={`radio-label ${!closeToTray ? 'active' : ''}`}>
<input
type="radio"
name="closeAction"
value="quit"
checked={!closeToTray}
onChange={() => setCloseToTray(false)}
/>
<div className="radio-content">
<span className="radio-title">退</span>
<span className="radio-desc">退</span>
</div>
</label>
</div>
</div>
)
const renderDatabaseTab = () => (
<div className="tab-content">
{/* 引导窗口按钮 */}
<div className="form-group">
<button className="btn btn-secondary" onClick={handleOpenWelcomeWindow}>
<Zap size={16} />
</button>
<span className="form-hint">使</span>
</div>
{/* 数据库解密部分 */}
<h3 className="section-title"></h3>
<div className="form-group">
<div className="toggle-setting">
<div className="toggle-header">
<label className="toggle-label">
<span className="toggle-title"></span>
<div className="toggle-switch">
<input
type="checkbox"
checked={autoUpdateDatabase}
onChange={(e) => setAutoUpdateDatabase(e.target.checked)}
/>
<span className="toggle-slider" />
</div>
</label>
</div>
<div className="toggle-description">
<p></p>
</div>
</div>
</div>
{/* 自动同步高级参数 - 仅在开启自动同步时显示 */}
{autoUpdateDatabase && (
<div className="form-group advanced-sync-settings">
<label></label>
<span className="form-hint"></span>
<div className="advanced-params-grid">
<div className="param-item">
<label></label>
<div className="number-control">
<button
className="control-btn minus"
onClick={() => setAutoUpdateCheckInterval(Math.max(10, autoUpdateCheckInterval - 10))}
disabled={autoUpdateCheckInterval <= 10}
>
<Minus size={14} />
</button>
<div className="value-display">
<input
type="text"
value={autoUpdateCheckInterval}
readOnly
/>
<span className="unit"></span>
</div>
<button
className="control-btn plus"
onClick={() => setAutoUpdateCheckInterval(Math.min(600, autoUpdateCheckInterval + 10))}
disabled={autoUpdateCheckInterval >= 600}
>
<Plus size={14} />
</button>
</div>
<span className="param-hint">10-600</span>
</div>
<div className="param-item">
<label></label>
<div className="number-control">
<button
className="control-btn minus"
onClick={() => setAutoUpdateMinInterval(Math.max(500, autoUpdateMinInterval - 100))}
disabled={autoUpdateMinInterval <= 500}
>
<Minus size={14} />
</button>
<div className="value-display">
<input
type="text"
value={autoUpdateMinInterval}
readOnly
/>
<span className="unit"></span>
</div>
<button
className="control-btn plus"
onClick={() => setAutoUpdateMinInterval(Math.min(10000, autoUpdateMinInterval + 100))}
disabled={autoUpdateMinInterval >= 10000}
>
<Plus size={14} />
</button>
</div>
<span className="param-hint">500-10000</span>
</div>
<div className="param-item">
<label></label>
<div className="number-control">
<button
className="control-btn minus"
onClick={() => setAutoUpdateDebounceTime(Math.max(100, autoUpdateDebounceTime - 100))}
disabled={autoUpdateDebounceTime <= 100}
>
<Minus size={14} />
</button>
<div className="value-display">
<input
type="text"
value={autoUpdateDebounceTime}
readOnly
/>
<span className="unit"></span>
</div>
<button
className="control-btn plus"
onClick={() => setAutoUpdateDebounceTime(Math.min(5000, autoUpdateDebounceTime + 100))}
disabled={autoUpdateDebounceTime >= 5000}
>
<Plus size={14} />
</button>
</div>
<span className="param-hint">100-5000</span>
</div>
</div>
<div className="preset-buttons">
<button
type="button"
className="btn btn-sm"
onClick={() => {
setAutoUpdateCheckInterval(30)
setAutoUpdateMinInterval(500)
setAutoUpdateDebounceTime(200)
}}
>
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => {
setAutoUpdateCheckInterval(60)
setAutoUpdateMinInterval(1000)
setAutoUpdateDebounceTime(500)
}}
>
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => {
setAutoUpdateCheckInterval(120)
setAutoUpdateMinInterval(3000)
setAutoUpdateDebounceTime(1000)
}}
>
</button>
</div>
</div>
)}
<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) => setDecryptKey(e.target.value)} />
<button type="button" className="toggle-visibility" onClick={() => setShowDecryptKey(!showDecryptKey)}>
{showDecryptKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
{keyStatus && <span className="key-status">{keyStatus}</span>}
<div className="btn-row">
<button className="btn btn-primary" onClick={handleGetKey} disabled={isGettingKey}>
<Key size={16} /> {isGettingKey ? '获取中...' : '自动获取密钥'}
</button>
{isGettingKey && <button className="btn btn-secondary" onClick={handleCancelGetKey}><X size={16} /> </button>}
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">xwechat_files </span>
<input type="text" placeholder="例如: C:\Users\xxx\Documents\xwechat_files" value={dbPath} onChange={(e) => setDbPath(e.target.value)} />
<button className="btn btn-primary" onClick={handleSelectDbPath}><FolderOpen size={16} /> </button>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<input
type="text"
placeholder="例如: wxid_xxxxxx 或其他账号目录名"
value={wxid}
onChange={(e) => {
setWxid(e.target.value)
setIsAccountVerified(false)
}}
/>
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleScanWxid} disabled={isScanningWxid}>
<Search size={16} /> {isScanningWxid ? '扫描中...' : '扫描账号目录'}
</button>
<button className="btn btn-secondary" onClick={handleVerifyAccountDirectory} disabled={isVerifyingAccount || !wxid || decryptKey.length !== 64}>
<Check size={16} /> {isVerifyingAccount ? '验证中...' : '验证账号目录'}
</button>
</div>
<span className="form-hint">{isAccountVerified ? '✅ 已验证' : '⚠️ 未验证'}</span>
{/* 多账号选择列表 */}
{showWxidDropdown && wxidOptions.length > 1 && (
<>
<div className="wxid-backdrop" onClick={() => setShowWxidDropdown(false)} />
<div className="wxid-select-list">
<div className="wxid-select-header">
<span> {wxidOptions.length} </span>
</div>
{wxidOptions.map((opt) => (
<div
key={opt}
className={`wxid-select-item ${opt === wxid ? 'active' : ''}`}
onClick={() => handleSelectWxid(opt)}
>
{opt}
</div>
))}
</div>
</>
)}
</div>
<div className="form-group">
<label> <span className="optional">()</span></label>
<span className="form-hint">使C盘</span>
<input type="text" placeholder="留空使用默认目录" value={cachePath} onChange={(e) => setCachePath(e.target.value)} />
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> </button>
<button className="btn btn-secondary" onClick={() => setCachePath('')}><RotateCcw size={16} /> </button>
</div>
</div>
<div className="form-group">
<div className="toggle-setting">
<div className="toggle-header">
<label className="toggle-label">
<span className="toggle-title"></span>
<span className="toggle-switch">
<input
type="checkbox"
checked={skipIntegrityCheck}
onChange={(e) => setSkipIntegrityCheck(e.target.checked)}
/>
<span className="toggle-slider"></span>
</span>
</label>
</div>
<div className="toggle-description">
<p></p>
<p className="toggle-warning">
<AlertCircle size={14} />
</p>
</div>
</div>
</div>
{/* 图片解密部分 */}
<h3 className="section-title" style={{ marginTop: '2rem' }}></h3>
<p className="section-desc">-CipherTalk</p>
<div className="form-group">
<label>XOR </label>
<span className="form-hint">2 0x53</span>
<div className="input-with-toggle">
<input type={showXorKey ? 'text' : 'password'} placeholder="例如: 0x12" value={imageXorKey} onChange={(e) => setImageXorKey(e.target.value)} />
<button type="button" className="toggle-visibility" onClick={() => setShowXorKey(!showXorKey)}>
{showXorKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</div>
<div className="form-group">
<label>AES </label>
<span className="form-hint">16V4版本图片需要</span>
<div className="input-with-toggle">
<input type={showAesKey ? 'text' : 'password'} placeholder="例如: b123456789012345..." value={imageAesKey} onChange={(e) => setImageAesKey(e.target.value)} />
<button type="button" className="toggle-visibility" onClick={() => setShowAesKey(!showAesKey)}>
{showAesKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</div>
{imageKeyStatus && <p className="key-status">{imageKeyStatus}</p>}
<button className="btn btn-primary" onClick={handleGetImageKey} disabled={isGettingImageKey}>
<ImageIcon size={16} /> {isGettingImageKey ? '获取中...' : '自动获取图片密钥'}
</button>
</div>
)
const [isGettingImageKey, setIsGettingImageKey] = useState(false)
const [imageKeyStatus, setImageKeyStatus] = useState('')
const handleGetImageKey = async () => {
if (isGettingImageKey) return
if (!dbPath) {
showMessage('请先配置数据库路径', false)
return
}
if (!wxid) {
showMessage('请先配置 wxid', false)
return
}
setIsGettingImageKey(true)
setImageKeyStatus('正在从缓存目录扫描图片密钥...')
try {
// 构建用户目录路径(用于 wxid 匹配)
const userDir = `${dbPath}\\${wxid}`
const removeListener = window.electronAPI.imageKey.onProgress((msg) => {
setImageKeyStatus(msg)
})
const result = await window.electronAPI.imageKey.getImageKeys(userDir)
removeListener()
if (result.success) {
if (result.xorKey !== undefined) {
const xorKeyHex = `0x${result.xorKey.toString(16).padStart(2, '0')}`
setImageXorKey(xorKeyHex)
await configService.setImageXorKey(xorKeyHex)
}
if (result.aesKey) {
setImageAesKey(result.aesKey)
await configService.setImageAesKey(result.aesKey)
}
showMessage('图片密钥获取成功!', true)
setImageKeyStatus('')
} else {
showMessage(result.error || '获取图片密钥失败', false)
setImageKeyStatus('')
}
} catch (e) {
showMessage(`获取图片密钥失败: ${e}`, false)
setImageKeyStatus('')
} finally {
setIsGettingImageKey(false)
}
}
// ========== 语音转文字 (STT) 相关状态 ==========
const [sttModelStatus, setSttModelStatus] = useState<{ exists: boolean; sizeBytes?: number } | null>(null)
const [isLoadingSttStatus, setIsLoadingSttStatus] = useState(false)
const [isDownloadingSttModel, setIsDownloadingSttModel] = useState(false)
const [sttDownloadProgress, setSttDownloadProgress] = useState(0)
// ========== Whisper GPU 加速相关状态 ==========
const [whisperGpuInfo, setWhisperGpuInfo] = useState<{ available: boolean; provider: string; info: string } | null>(null)
const [whisperModelType, setWhisperModelType] = useState<'tiny' | 'base' | 'small' | 'medium' | 'large-v3' | 'large-v3-turbo' | 'large-v3-turbo-q5' | 'large-v3-turbo-q8'>('small')
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; sizeBytes?: number } | null>(null)
const [isLoadingWhisperStatus, setIsLoadingWhisperStatus] = useState(false)
const [isDownloadingWhisperModel, setIsDownloadingWhisperModel] = useState(false)
const [whisperDownloadProgress, setWhisperDownloadProgress] = useState(0)
const [useWhisperGpu, setUseWhisperGpu] = useState(false)
// GPU 组件状态
const [gpuComponentsStatus, setGpuComponentsStatus] = useState<{ installed: boolean; missingFiles?: string[]; gpuDir?: string } | null>(null)
const [isDownloadingGpuComponents, setIsDownloadingGpuComponents] = useState(false)
const [gpuDownloadProgress, setGpuDownloadProgress] = useState({ overallProgress: 0, currentFile: '' })
// ========== STT 模式切换 ==========
const [sttMode, setSttMode] = useState<'cpu' | 'gpu'>('cpu')
// 加载 STT 模型状态
useEffect(() => {
if (activeTab === 'stt') {
loadSttModelStatus()
loadWhisperStatus()
loadSttMode()
checkGpuComponents()
}
}, [activeTab])
const loadSttMode = async () => {
const savedMode = await window.electronAPI.config.get('sttMode') as 'cpu' | 'gpu' | undefined
setSttMode(savedMode || 'cpu')
}
const handleSttModeChange = async (mode: 'cpu' | 'gpu') => {
setSttMode(mode)
await window.electronAPI.config.set('sttMode', mode)
showMessage(mode === 'cpu' ? '已切换到 CPU 模式 (SenseVoice)' : '已切换到 GPU 模式 (Whisper)', true)
}
// 监听 STT 下载进度
useEffect(() => {
const removeListener = window.electronAPI.stt.onDownloadProgress((progress) => {
setSttDownloadProgress(progress.percent || 0)
})
return () => removeListener()
}, [])
const loadSttModelStatus = async () => {
setIsLoadingSttStatus(true)
try {
const result = await window.electronAPI.stt.getModelStatus()
if (result.success) {
setSttModelStatus({
exists: result.exists || false,
sizeBytes: result.sizeBytes
})
}
} catch (e) {
console.error('获取 STT 模型状态失败:', e)
} finally {
setIsLoadingSttStatus(false)
}
}
const handleDownloadSttModel = async () => {
if (isDownloadingSttModel) return
setIsDownloadingSttModel(true)
setSttDownloadProgress(0)
try {
showMessage('正在下载语音识别模型...', true)
const result = await window.electronAPI.stt.downloadModel()
if (result.success) {
showMessage('语音识别模型下载完成!', true)
await loadSttModelStatus()
} else {
showMessage(result.error || '模型下载失败', false)
}
} catch (e) {
showMessage(`模型下载失败: ${e}`, false)
} finally {
setIsDownloadingSttModel(false)
}
}
const handleSttLanguageToggle = async (lang: string) => {
if (sttLanguages.includes(lang) && sttLanguages.length === 1) {
showMessage('必须至少选择一种语言', false)
return
}
const newLangs = sttLanguages.includes(lang)
? sttLanguages.filter(l => l !== lang)
: [...sttLanguages, lang]
setSttLanguagesState(newLangs)
await configService.setSttLanguages(newLangs)
}
const handleSttModelTypeChange = async (type: 'int8' | 'float32') => {
if (type === sttModelType) return
// 如果已下载模型,切换类型需要重新下载
if (sttModelStatus?.exists) {
const confirmSwitch = confirm(
`切换模型类型需要重新下载模型。\n\n` +
`当前: ${sttModelTypeOptions.find(o => o.value === sttModelType)?.label}\n` +
`切换到: ${sttModelTypeOptions.find(o => o.value === type)?.label} (${sttModelTypeOptions.find(o => o.value === type)?.size})\n\n` +
`确定要切换吗?`
)
if (!confirmSwitch) return
// 清除当前模型
try {
await window.electronAPI.stt.clearModel()
} catch (e) {
console.error('清除模型失败:', e)
}
}
setSttModelType(type)
await configService.setSttModelType(type)
await loadSttModelStatus()
showMessage(`模型类型已切换为 ${sttModelTypeOptions.find(o => o.value === type)?.label}`, true)
}
// ========== Whisper GPU 相关函数 ==========
const loadWhisperStatus = async () => {
setIsLoadingWhisperStatus(true)
try {
// 加载保存的模型类型
const savedModelType = await window.electronAPI.config.get('whisperModelType') as 'tiny' | 'base' | 'small' | 'medium' | 'large-v3' | 'large-v3-turbo' | 'large-v3-turbo-q5' | 'large-v3-turbo-q8' | undefined
const modelType = savedModelType || 'small'
setWhisperModelType(modelType)
const gpuInfo = await window.electronAPI.sttWhisper.detectGPU()
setWhisperGpuInfo(gpuInfo)
const modelStatus = await window.electronAPI.sttWhisper.checkModel(modelType)
setWhisperModelStatus(modelStatus)
const savedUseWhisper = await window.electronAPI.config.get('useWhisperGpu') as boolean | undefined
setUseWhisperGpu(savedUseWhisper || false)
} catch (e) {
console.error('加载 Whisper 状态失败:', e)
} finally {
setIsLoadingWhisperStatus(false)
}
}
const handleDownloadWhisperModel = async () => {
if (isDownloadingWhisperModel) return
setIsDownloadingWhisperModel(true)
setWhisperDownloadProgress(0)
const unsubscribe = window.electronAPI.sttWhisper.onDownloadProgress((progress) => {
if (progress.percent) {
setWhisperDownloadProgress(progress.percent)
}
})
try {
const result = await window.electronAPI.sttWhisper.downloadModel(whisperModelType)
if (result.success) {
showMessage('Whisper 模型下载完成!', true)
await loadWhisperStatus()
} else {
showMessage(result.error || 'Whisper 模型下载失败', false)
}
} catch (e) {
showMessage(`Whisper 模型下载失败: ${e}`, false)
} finally {
unsubscribe()
setIsDownloadingWhisperModel(false)
}
}
const handleWhisperModelTypeChange = async (type: 'tiny' | 'base' | 'small' | 'medium' | 'large-v3' | 'large-v3-turbo' | 'large-v3-turbo-q5' | 'large-v3-turbo-q8') => {
console.log('[SettingsPage] 切换 Whisper 模型类型:', type)
setWhisperModelType(type)
await window.electronAPI.config.set('whisperModelType', type)
console.log('[SettingsPage] Whisper 模型类型已保存')
await loadWhisperStatus()
}
// ========== GPU 组件管理 ==========
const checkGpuComponents = async () => {
try {
const status = await window.electronAPI.sttWhisper.checkGPUComponents()
setGpuComponentsStatus(status)
} catch (e) {
console.error('检查 GPU 组件失败:', e)
}
}
const handleDownloadGpuComponents = async () => {
if (isDownloadingGpuComponents) return
// 检查是否设置了缓存目录
if (!cachePath) {
showMessage('请先设置缓存目录', false)
return
}
if (!confirm('下载 GPU 组件约 645 MB,确定要下载吗?\n下载后将自动安装到缓存目录。')) {
return
}
setIsDownloadingGpuComponents(true)
setGpuDownloadProgress({ overallProgress: 0, currentFile: '' })
const unsubscribe = window.electronAPI.sttWhisper.onGPUDownloadProgress((progress) => {
setGpuDownloadProgress({
overallProgress: progress.overallProgress,
currentFile: progress.currentFile
})
})
try {
const result = await window.electronAPI.sttWhisper.downloadGPUComponents()
if (result.success) {
showMessage('GPU 组件下载完成!', true)
await checkGpuComponents()
await loadWhisperStatus()
} else {
showMessage(result.error || 'GPU 组件下载失败', false)
}
} catch (e) {
showMessage(`GPU 组件下载失败: ${e}`, false)
} finally {
unsubscribe()
setIsDownloadingGpuComponents(false)
}
}
const handleToggleWhisperGpu = async (enabled: boolean) => {
setUseWhisperGpu(enabled)
await window.electronAPI.config.set('useWhisperGpu', enabled)
showMessage(enabled ? 'Whisper GPU 加速已启用' : 'Whisper GPU 加速已禁用', true)
}
const renderSttTab = () => (
<div className="tab-content">
{/* STT 模式切换器 */}
<div className="theme-mode-toggle" style={{ marginBottom: '2rem' }}>
<button
className={`mode-btn ${sttMode === 'cpu' ? 'active' : ''}`}
onClick={() => handleSttModeChange('cpu')}
>
<Layers size={16} /> CPU
</button>
<button
className={`mode-btn ${sttMode === 'gpu' ? 'active' : ''}`}
onClick={() => handleSttModeChange('gpu')}
>
<Zap size={16} /> GPU
</button>
</div>
{/* CPU 模式 - SenseVoice */}
{sttMode === 'cpu' && (
<>
<h3 className="section-title"> (SenseVoice)</h3>
<p className="section-desc">
使 SenseVoice 线
</p>
<h4 className="subsection-title" style={{ marginTop: '1rem', marginBottom: '0.5rem', fontSize: '0.95rem', fontWeight: 500 }}></h4>
<div className="model-type-grid">
{sttModelTypeOptions.map(opt => (
<label
key={opt.value}
className={`model-card ${sttModelType === opt.value ? 'active' : ''} ${isDownloadingSttModel ? 'disabled' : ''}`}
>
<input
type="radio"
name="sttModelType"
value={opt.value}
checked={sttModelType === opt.value}
onChange={() => handleSttModelTypeChange(opt.value as 'int8' | 'float32')}
disabled={isDownloadingSttModel}
/>
<div className="model-icon">
{opt.value === 'int8' ? <Zap size={24} /> : <Layers size={24} />}
</div>
<div className="model-info">
<div className="model-header">
<span className="model-name">{opt.label}</span>
<span className="model-size">{opt.size}</span>
</div>
<span className="model-desc">{opt.desc}</span>
</div>
{sttModelType === opt.value && <div className="model-check"><Check size={14} /></div>}
</label>
))}
</div>
<div className="stt-model-status">
{isLoadingSttStatus ? (
<p>...</p>
) : sttModelStatus ? (
<div className="model-info">
<div className={`status-indicator ${sttModelStatus.exists ? 'ready' : 'missing'}`}>
{sttModelStatus.exists ? (
<>
<CheckCircle size={20} />
<span></span>
</>
) : (
<>
<AlertCircle size={20} />
<span></span>
</>
)}
</div>
{sttModelStatus.exists && sttModelStatus.sizeBytes && (
<p className="model-size">: {formatFileSize(sttModelStatus.sizeBytes)}</p>
)}
</div>
) : (
<p></p>
)}
</div>
{isDownloadingSttModel && (
<div className="download-progress">
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${sttDownloadProgress}%` }} />
</div>
<span className="progress-text">{sttDownloadProgress.toFixed(1)}%</span>
</div>
)}
<h3 className="section-title" style={{ marginTop: '2rem' }}></h3>
<p className="section-desc"></p>
<div className="language-grid">
{sttLanguageOptions.map(opt => (
<label
key={opt.value}
className={`language-card ${sttLanguages.includes(opt.value) ? 'active' : ''}`}
>
<input
type="checkbox"
checked={sttLanguages.includes(opt.value)}
onChange={() => handleSttLanguageToggle(opt.value)}
disabled={sttLanguages.includes(opt.value) && sttLanguages.length === 1}
/>
<div className="lang-info">
<span className="lang-name">{opt.label}</span>
<span className="lang-en">{opt.enLabel}</span>
</div>
{sttLanguages.includes(opt.value) && <div className="lang-check"><Check size={14} /></div>}
</label>
))}
</div>
<div className="btn-row" style={{ marginTop: '1rem' }}>
{!sttModelStatus?.exists && (
<button
className="btn btn-primary"
onClick={handleDownloadSttModel}
disabled={isDownloadingSttModel}
>
<Download size={16} /> {isDownloadingSttModel ? '下载中...' : '下载模型'}
</button>
)}
{sttModelStatus?.exists && (
<button
className="btn btn-danger"
onClick={async () => {
const currentModelSize = sttModelTypeOptions.find(o => o.value === sttModelType)?.size || '235 MB'
if (confirm(`确定要清除语音识别模型吗?下次使用需要重新下载 (${currentModelSize})。`)) {
try {
const result = await window.electronAPI.stt.clearModel()
if (result.success) {
showMessage('模型清除成功', true)
await loadSttModelStatus()
} else {
showMessage(result.error || '模型清除失败', false)
}
} catch (e) {
showMessage(`模型清除失败: ${e}`, false)
}
}
}}
>
<Trash2 size={16} />
</button>
)}
<button
className="btn btn-secondary"
onClick={loadSttModelStatus}
disabled={isLoadingSttStatus}
>
<RefreshCw size={16} className={isLoadingSttStatus ? 'spin' : ''} />
</button>
</div>
</>
)}
{/* GPU 模式 - Whisper */}
{sttMode === 'gpu' && (
<>
<h3 className="section-title"> (Whisper GPU)</h3>
<p className="section-desc">
使 Whisper.cpp GPU 10-15 NVIDIA GPU (CUDA)
</p>
{/* GPU 状态卡片 */}
<div className="gpu-status-card" style={{
padding: '1rem',
background: 'var(--bg-secondary)',
borderRadius: '12px',
marginBottom: '1.5rem',
border: '1px solid var(--border-color)'
}}>
{isLoadingWhisperStatus ? (
<p style={{ margin: 0, color: 'var(--text-secondary)' }}> GPU...</p>
) : whisperGpuInfo ? (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
{whisperGpuInfo.available ? (
<CheckCircle size={20} style={{ color: 'var(--success-color)' }} />
) : (
<AlertCircle size={20} style={{ color: 'var(--warning-color)' }} />
)}
<strong style={{ fontSize: '15px' }}>{whisperGpuInfo.provider}</strong>
</div>
<p style={{ margin: 0, fontSize: '13px', color: 'var(--text-secondary)', lineHeight: '1.5' }}>
{whisperGpuInfo.info}
</p>
</div>
) : (
<p style={{ margin: 0, color: 'var(--text-secondary)' }}> GPU </p>
)}
</div>
{/* GPU 组件状态 */}
<div className="gpu-components-card" style={{
padding: '1.25rem',
background: 'var(--bg-secondary)',
borderRadius: '12px',
marginBottom: '1.5rem',
border: '1px solid var(--border-color)',
transition: 'all 0.3s ease'
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.75rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{
width: '32px',
height: '32px',
borderRadius: '8px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<Download size={18} color="white" />
</div>
<strong style={{ fontSize: '15px' }}>GPU </strong>
</div>
{gpuComponentsStatus?.installed ? (
<span style={{
fontSize: '13px',
color: 'var(--success-color)',
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.75rem',
background: 'var(--success-bg)',
borderRadius: '12px',
fontWeight: 500
}}>
<CheckCircle size={16} />
</span>
) : (
<span style={{
fontSize: '13px',
color: 'var(--warning-color)',
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.75rem',
background: 'var(--warning-bg)',
borderRadius: '12px',
fontWeight: 500
}}>
<AlertCircle size={16} />
</span>
)}
</div>
{gpuComponentsStatus?.installed ? (
<div style={{
padding: '0.75rem',
background: 'var(--bg-tertiary)',
borderRadius: '8px',
fontSize: '13px',
color: 'var(--text-secondary)',
wordBreak: 'break-all'
}}>
<div style={{ marginBottom: '0.25rem', color: 'var(--text-primary)', fontWeight: 500 }}>
</div>
{gpuComponentsStatus.gpuDir}
</div>
) : (
<>
<div style={{
padding: '0.75rem',
background: 'var(--bg-tertiary)',
borderRadius: '8px',
marginBottom: '1rem'
}}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}>
<AlertCircle size={16} style={{ marginTop: '2px', flexShrink: 0, color: 'var(--primary-color)' }} />
<div style={{ fontSize: '13px', color: 'var(--text-secondary)', lineHeight: '1.5' }}>
GPU <strong style={{ color: 'var(--text-primary)' }}>645 MB</strong> CUDA
<br />
</div>
</div>
</div>
{isDownloadingGpuComponents ? (
<div>
<div style={{
marginBottom: '0.75rem',
fontSize: '13px',
color: 'var(--text-primary)',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '0.5rem'
}}>
<div className="spinner" style={{
width: '14px',
height: '14px',
border: '2px solid var(--border-color)',
borderTopColor: 'var(--primary-color)',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite'
}} />
{gpuDownloadProgress.currentFile}
</div>
<div style={{
background: 'var(--bg-tertiary)',
borderRadius: '8px',
overflow: 'hidden',
height: '8px',
position: 'relative'
}}>
<div style={{
width: `${gpuDownloadProgress.overallProgress}%`,
height: '100%',
background: 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)',
transition: 'width 0.3s ease',
position: 'relative',
overflow: 'hidden'
}}>
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent)',
animation: 'shimmer 1.5s infinite'
}} />
</div>
</div>
<div style={{
marginTop: '0.75rem',
fontSize: '13px',
textAlign: 'center',
color: 'var(--text-secondary)',
fontWeight: 500
}}>
{gpuDownloadProgress.overallProgress.toFixed(1)}%
</div>
</div>
) : (
<button
className="btn-primary"
onClick={handleDownloadGpuComponents}
style={{
width: '100%',
padding: '0.75rem 1rem',
borderRadius: '9999px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
fontSize: '14px',
fontWeight: 500
}}
>
<Download size={16} />
GPU (645 MB)
</button>
)}
</>
)}
</div>
{/* 模型选择 */}
<h4 className="subsection-title" style={{ marginTop: '1rem', marginBottom: '0.5rem', fontSize: '0.95rem', fontWeight: 500 }}></h4>
<div className="model-type-grid">
{[
{ value: 'tiny', label: 'Tiny 模型', size: '75 MB', desc: '最快速度,适合实时场景' },
{ value: 'base', label: 'Base 模型', size: '145 MB', desc: '推荐使用,速度与精度平衡' },
{ value: 'small', label: 'Small 模型', size: '488 MB', desc: '更高精度,适合准确识别' },
{ value: 'large-v3-turbo-q5', label: 'Turbo-Q5 量化', size: '540 MB', desc: '极高精度 + 小体积(推荐)' },
{ value: 'large-v3-turbo-q8', label: 'Turbo-Q8 量化', size: '835 MB', desc: '极高精度 + 高质量量化' },
{ value: 'medium', label: 'Medium 模型', size: '1.5 GB', desc: '最佳精度,需要更多时间' },
{ value: 'large-v3-turbo', label: 'Large-v3-Turbo', size: '1.62 GB', desc: '极高精度 + 快速' },
{ value: 'large-v3', label: 'Large-v3 模型', size: '3.1 GB', desc: '极高精度,专业级识别' }
].map(opt => (
<label
key={opt.value}
className={`model-card ${whisperModelType === opt.value ? 'active' : ''} ${isDownloadingWhisperModel ? 'disabled' : ''}`}
>
<input
type="radio"
name="whisperModelType"
value={opt.value}
checked={whisperModelType === opt.value}
onChange={() => handleWhisperModelTypeChange(opt.value as any)}
disabled={isDownloadingWhisperModel}
/>
<div className="model-icon">
<Zap size={24} />
</div>
<div className="model-info">
<div className="model-header">
<span className="model-name">{opt.label}</span>
<span className="model-size">{opt.size}</span>
</div>
<span className="model-desc">{opt.desc}</span>
</div>
{whisperModelType === opt.value && <div className="model-check"><Check size={14} /></div>}
</label>
))}
</div>
{/* 模型状态 */}
<div className="stt-model-status">
{isLoadingWhisperStatus ? (
<p>...</p>
) : whisperModelStatus ? (
<div className="model-info">
<div className={`status-indicator ${whisperModelStatus.exists ? 'ready' : 'missing'}`}>
{whisperModelStatus.exists ? (
<>
<CheckCircle size={20} />
<span></span>
</>
) : (
<>
<AlertCircle size={20} />
<span></span>
</>
)}
</div>
{whisperModelStatus.exists && whisperModelStatus.sizeBytes && (
<p className="model-size">: {formatFileSize(whisperModelStatus.sizeBytes)}</p>
)}
</div>
) : (
<p></p>
)}
</div>
{/* 下载进度 */}
{isDownloadingWhisperModel && (
<div className="download-progress">
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${whisperDownloadProgress}%` }} />
</div>
<span className="progress-text">{whisperDownloadProgress.toFixed(1)}%</span>
</div>
)}
{/* 操作按钮 */}
<div className="btn-row" style={{ marginTop: '1rem' }}>
{!whisperModelStatus?.exists && (
<button
className="btn btn-primary"
onClick={handleDownloadWhisperModel}
disabled={isDownloadingWhisperModel}
>
<Download size={16} /> {isDownloadingWhisperModel ? '下载中...' : '下载模型'}
</button>
)}
{whisperModelStatus?.exists && (
<button
className="btn btn-danger"
onClick={async () => {
const modelSizes = {
tiny: '75 MB',
base: '145 MB',
small: '488 MB',
medium: '1.5 GB',
'large-v3': '3.1 GB',
'large-v3-turbo': '1.62 GB',
'large-v3-turbo-q5': '540 MB',
'large-v3-turbo-q8': '835 MB'
}
const currentModelSize = modelSizes[whisperModelType]
if (confirm(`确定要清除 Whisper 模型吗?下次使用需要重新下载 (${currentModelSize})。`)) {
try {
const result = await window.electronAPI.sttWhisper.clearModel(whisperModelType)
if (result.success) {
showMessage('模型清除成功', true)
await loadWhisperStatus()
} else {
showMessage(result.error || '模型清除失败', false)
}
} catch (e) {
showMessage(`模型清除失败: ${e}`, false)
}
}
}}
>
<Trash2 size={16} />
</button>
)}
<button
className="btn btn-secondary"
onClick={loadWhisperStatus}
disabled={isLoadingWhisperStatus}
>
<RefreshCw size={16} className={isLoadingWhisperStatus ? 'spin' : ''} />
</button>
</div>
</>
)}
<h3 className="section-title" style={{ marginTop: '2rem' }}>使</h3>
<div className="stt-instructions">
<ol>
<li> CPU GPU </li>
<li></li>
<li></li>
<li>"转文字"</li>
</ol>
<p className="note">
<strong></strong>
</p>
</div>
</div>
)
const handleSecurityMethodSelect = async (method: 'biometric' | 'password') => {
// 1. 如果点击的是当前已激活的方法 -> 关闭
if (isAuthEnabled && authMethod === method) {
await disableAuth()
showMessage('已关闭应用锁', true)
if (method === 'password') {
setShowPasswordInput(false)
setPasswordInput('')
}
return
}
// 2. 如果点击的是另一个方法 -> 确认切换
if (isAuthEnabled && authMethod !== method) {
setSecurityConfirm({
show: true,
title: '切换认证方式',
message: method === 'biometric'
? '切换到 Windows Hello 将清除当前的密码设置,是否继续?'
: '切换到密码认证将清除当前的生物识别设置,是否继续?',
onConfirm: async () => {
await disableAuth()
if (method === 'biometric') {
activateBiometric()
} else {
setShowPasswordInput(true)
}
setSecurityConfirm(prev => ({ ...prev, show: false }))
}
})
return
}
// 3. 如果当前未激活任何方法 -> 直接开启
if (method === 'biometric') {
activateBiometric()
} else {
setShowPasswordInput(true)
}
}
const activateBiometric = async () => {
showMessage('正在等待 Windows Hello 验证...', true)
const result = await enableAuth()
if (result.success) {
showMessage('已启用 Windows Hello', true)
setShowPasswordInput(false)
} else {
showMessage(result.error || '启用失败', false)
}
}
const renderSecurityTab = () => (
<div className="tab-content">
<h3 className="section-title"></h3>
<div className="section-desc"></div>
<div className="security-grid">
{/* Windows Hello Card */}
<div
className={`security-card ${isAuthEnabled && authMethod === 'biometric' ? 'active' : ''}`}
onClick={() => handleSecurityMethodSelect('biometric')}
style={{ cursor: 'pointer' }}
>
<div className="security-preview-area">
<div className="preview-lock-screen">
<div className="preview-avatar">
<Lock size={20} />
</div>
<div className="preview-badge">
<Fingerprint /> Windows Hello
</div>
<div className="preview-btn" />
</div>
</div>
<div className="security-content">
<div className="security-header">
<span className="security-title">Windows Hello</span>
{isAuthEnabled && authMethod === 'biometric' && (
<div className="theme-check" style={{ position: 'relative', top: 0, right: 0, transform: 'scale(1)', background: 'var(--primary)', boxShadow: 'none' }}>
<Check size={12} />
</div>
)}
</div>
<div className="security-desc">
使 PIN
</div>
</div>
</div>
{/* Custom Password Card */}
<div
className={`security-card ${isAuthEnabled && authMethod === 'password' ? 'active' : ''}`}
onClick={() => handleSecurityMethodSelect('password')}
style={{ cursor: 'pointer' }}
>
<div className="security-preview-area">
<div className="preview-lock-screen">
<div className="preview-avatar">
<ShieldCheck size={20} />
</div>
<div className="preview-input" />
<div className="preview-btn" style={{ width: '32px' }} />
</div>
</div>
<div className="security-content">
<div className="security-header">
<span className="security-title"></span>
{isAuthEnabled && authMethod === 'password' && (
<div className="theme-check" style={{ position: 'relative', top: 0, right: 0, transform: 'scale(1)', background: 'var(--primary)', boxShadow: 'none' }}>
<Check size={12} />
</div>
)}
</div>
<div className="security-desc">
便使
</div>
{/* Input area - prevent click propagation to avoid toggling card off while typing */}
{(showPasswordInput || (isAuthEnabled && authMethod === 'password')) && (
<div
className="password-setup-inline"
onClick={(e) => e.stopPropagation()}
style={{ cursor: 'default' }}
>
<label className="field-label">
{authMethod === 'password' ? '修改密码 (留空不修改)' : '设置新密码'}
</label>
<div className="password-input-row">
<input
type="password"
className="field-input"
value={passwordInput}
onChange={(e) => setPasswordInput(e.target.value)}
placeholder="******"
/>
<button
className="btn btn-primary"
disabled={!passwordInput}
onClick={async () => {
if (!passwordInput) return
const result = await setupPassword(passwordInput)
if (result.success) {
showMessage(authMethod === 'password' ? '密码已更新' : '已启用密码锁', true)
setPasswordInput('')
setShowPasswordInput(false)
} else {
showMessage(result.error || '设置失败', false)
}
}}
>
<Save size={14} />
</button>
</div>
</div>
)}
</div>
</div>
</div>
{/* Confirmation Modal */}
{securityConfirm.show && (
<div className="clear-dialog-overlay">
<div className="clear-dialog">
<h3 style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<AlertCircle className="text-warning" size={20} color="#f59e0b" />
{securityConfirm.title}
</h3>
<p>{securityConfirm.message}</p>
<div className="dialog-actions">
<button
className="btn btn-secondary"
onClick={() => setSecurityConfirm(prev => ({ ...prev, show: false }))}
>
</button>
<button
className="btn btn-primary"
onClick={securityConfirm.onConfirm}
>
</button>
</div>
</div>
</div>
)}
</div>
)
const renderDataManagementTab = () => (
<div className="tab-content">
{/* 导出设置 */}
<section className="settings-section">
<h3 className="section-title"></h3>
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<input type="text" placeholder={defaultExportPath || '系统下载目录'} value={exportPath || defaultExportPath} onChange={(e) => setExportPath(e.target.value)} />
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleSelectExportPath}><FolderOpen size={16} /> </button>
<button className="btn btn-secondary" onClick={handleResetExportPath}><RotateCcw size={16} /> </button>
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">0</span>
<div className="date-range-options">
{[
{ value: 0, label: '不限制', desc: '全部消息' },
{ value: 1, label: '今天', desc: '仅今日消息' },
{ value: 7, label: '最近7天', desc: '过去一周' },
{ value: 30, label: '最近30天', desc: '过去一个月' },
{ value: 90, label: '最近90天', desc: '过去三个月' },
{ value: 180, label: '最近180天', desc: '过去半年' },
{ value: 365, label: '最近1年', desc: '过去一年' }
].map(option => (
<label
key={option.value}
className={`date-range-card ${exportDefaultDateRange === option.value ? 'active' : ''}`}
>
<input
type="radio"
name="exportDefaultDateRange"
value={option.value}
checked={exportDefaultDateRange === option.value}
onChange={(e) => setExportDefaultDateRange(Number(e.target.value))}
/>
<div className="date-range-content">
<span className="date-range-label">{option.label}</span>
<span className="date-range-desc">{option.desc}</span>
</div>
{exportDefaultDateRange === option.value && (
<div className="date-range-check"><Check size={14} /></div>
)}
</label>
))}
</div>
</div>
<div className="form-group">
<label></label>
<div className="export-default-options">
<label className={`export-option-card ${exportDefaultAvatars ? 'active' : ''}`}>
<input
type="checkbox"
checked={exportDefaultAvatars}
onChange={(e) => setExportDefaultAvatars(e.target.checked)}
/>
<div className="option-content">
<div className="option-icon">
<User size={20} />
</div>
<div className="option-info">
<span className="option-label"></span>
<span className="option-desc"></span>
</div>
</div>
{exportDefaultAvatars && (
<div className="option-check"><Check size={14} /></div>
)}
</label>
</div>
</div>
</section>
<div className="divider" style={{ margin: '2rem 0', borderBottom: '1px solid var(--border-color)', opacity: 0.1 }} />
{/* 缓存管理 */}
<section className="settings-section cache-management">
<h3 className="section-title"></h3>
{isLoadingCacheSize ? (
<p className="cache-loading">...</p>
) : cacheSize ? (
<div className="cache-cards">
<div className="cache-card">
<div className="cache-card-header">
<ImageIcon size={20} className="cache-card-icon" />
<span className="cache-card-label"></span>
</div>
<div className="cache-card-size">{formatFileSize(cacheSize.images)}</div>
<button type="button" className="btn btn-secondary cache-card-btn" onClick={handleClearImages}>
<Trash2 size={14} />
</button>
</div>
<div className="cache-card">
<div className="cache-card-header">
<Smile size={20} className="cache-card-icon" />
<span className="cache-card-label"></span>
</div>
<div className="cache-card-size">{formatFileSize(cacheSize.emojis)}</div>
<button type="button" className="btn btn-secondary cache-card-btn" onClick={handleClearEmojis}>
<Trash2 size={14} />
</button>
</div>
<div className="cache-card">
<div className="cache-card-header">
<Database size={20} className="cache-card-icon" />
<span className="cache-card-label"></span>
</div>
<div className="cache-card-size">{formatFileSize(cacheSize.databases)}</div>
<button type="button" className="btn btn-secondary cache-card-btn" onClick={handleClearDatabases}>
<Trash2 size={14} />
</button>
</div>
<div className="cache-card cache-card-config">
<div className="cache-card-header">
<Key size={20} className="cache-card-icon" />
<span className="cache-card-label"></span>
</div>
<div className="cache-card-desc"></div>
<button type="button" className="btn btn-secondary cache-card-btn" onClick={handleClearConfig}>
<Trash2 size={14} />
</button>
</div>
<div className="cache-card cache-card-total">
<div className="cache-card-header">
<Layers size={20} className="cache-card-icon" />
<span className="cache-card-label"></span>
</div>
<div className="cache-card-size">{formatFileSize(cacheSize.total)}</div>
<button type="button" className="btn btn-danger cache-card-btn" onClick={handleClearAllCache}>
<Trash2 size={14} />
</button>
</div>
</div>
) : (
<p></p>
)}
</section>
<div className="divider" style={{ margin: '2rem 0', borderBottom: '1px solid var(--border-color)', opacity: 0.1 }} />
{/* 日志管理 */}
<section className="settings-section">
<h3 className="section-title"></h3>
<div className="form-group">
<div className="log-stats-lite" style={{ display: 'flex', gap: '1rem', alignItems: 'center', marginBottom: '1rem' }}>
<span className="log-value">: {logFiles.length}</span>
<span className="log-value">: {formatFileSize(logSize)}</span>
<span className="log-value">: {currentLogLevel}</span>
</div>
<div className="log-level-options" style={{ marginBottom: '1rem' }}>
{['DEBUG', 'INFO', 'WARN', 'ERROR'].map((level) => (
<button
key={level}
className={`log-level-btn ${currentLogLevel === level ? 'active' : ''}`}
onClick={() => handleLogLevelChange(level)}
>
{level}
</button>
))}
</div>
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleOpenLogDirectory}>
<FolderOpen size={16} />
</button>
<button className="btn btn-secondary" onClick={loadLogFiles} disabled={isLoadingLogs}>
<RefreshCw size={16} className={isLoadingLogs ? 'spin' : ''} />
</button>
<button className="btn btn-danger" onClick={handleClearLogs}>
<Trash2 size={16} />
</button>
</div>
</div>
<div className="log-files" style={{ marginTop: '1rem' }}>
<h4></h4>
{isLoadingLogs ? (
<p>...</p>
) : logFiles.length > 0 ? (
<div className="log-file-list" style={{ maxHeight: '200px', overflowY: 'auto' }}>
{logFiles.map((file) => (
<div
key={file.name}
className={`log-file-item ${selectedLogFile === file.name ? 'selected' : ''}`}
onClick={() => handleLogFileSelect(file.name)}
>
<div className="log-file-info">
<span className="log-file-name">{file.name}</span>
<span className="log-file-size">{formatFileSize(file.size)}</span>
</div>
</div>
))}
</div>
) : (
<p></p>
)}
</div>
{selectedLogFile && (
<div className="log-content log-content-selectable" style={{ marginTop: '1rem' }}>
<div className="log-content-text" style={{ maxHeight: '300px', overflowY: 'auto' }}>
<pre>{logContent}</pre>
</div>
</div>
)}
</section>
</div>
)
const getTypeDisplayName = (type: string | null) => {
if (!type) return '未激活'
const typeMap: Record<string, string> = {
'30days': '30天试用版',
'90days': '90天标准版',
'365days': '365天专业版',
'permanent': '永久版'
}
return typeMap[type] || type
}
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '永久'
return new Date(dateStr).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
const renderActivationTab = () => (
<div className="tab-content activation-tab">
<div className={`activation-status-card ${activationStatus?.isActivated ? 'activated' : 'inactive'}`}>
<div className="status-icon">
{activationStatus?.isActivated ? (
<CheckCircle size={48} />
) : (
<AlertCircle size={48} />
)}
</div>
<div className="status-content">
<h3>{activationStatus?.isActivated ? '已激活' : '未激活'}</h3>
{activationStatus?.isActivated && (
<>
<p className="status-type">{getTypeDisplayName(activationStatus.type)}</p>
{activationStatus.daysRemaining !== null && activationStatus.type !== 'permanent' && (
<p className="status-expires">
<Clock size={14} />
{activationStatus.daysRemaining > 0
? `剩余 ${activationStatus.daysRemaining}`
: '已过期'}
</p>
)}
{activationStatus.expiresAt && (
<p className="status-date">{formatDate(activationStatus.expiresAt)}</p>
)}
{activationStatus.activatedAt && (
<p className="status-date">{formatDate(activationStatus.activatedAt)}</p>
)}
</>
)}
</div>
</div>
<div className="device-info-card">
<h4></h4>
<div className="device-id-row">
<span className="label"></span>
<code>{activationStatus?.deviceId || '获取中...'}</code>
</div>
</div>
<div className="activation-actions">
<button className="btn btn-secondary" onClick={() => checkActivationStatus()}>
<RefreshCw size={16} />
</button>
<button className="btn btn-primary" onClick={() => window.electronAPI.window.openPurchaseWindow()}>
<Key size={16} />
</button>
</div>
</div>
)
// 检查导航传递的更新信息
useEffect(() => {
if (location.state?.updateInfo) {
setUpdateInfo(location.state.updateInfo)
}
}, [location.state])
const renderAboutTab = () => (
<div className="tab-content about-tab">
<div className="about-card">
<div className="about-logo">
<img src={appIcon === 'xinnian' ? "./xinnian.png" : "./logo.png"} alt="密语" />
</div>
<h2 className="about-name"></h2>
<p className="about-slogan">CipherTalk</p>
<p className="about-version">v{appVersion || '...'}</p>
<div className="about-update">
{updateInfo?.hasUpdate ? (
<>
<p className="update-hint"> v{updateInfo.version} </p>
{isDownloading ? (
<div className="download-progress">
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${downloadProgress}%` }} />
</div>
<span>{downloadProgress.toFixed(0)}%</span>
</div>
) : (
<button className="btn btn-primary" onClick={handleUpdateNow}>
<Download size={16} />
</button>
)}
</>
) : (
<button className="btn btn-secondary" onClick={handleCheckUpdate} disabled={isCheckingUpdate}>
<RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} />
{isCheckingUpdate ? '检查中...' : '检查更新'}
</button>
)}
</div>
</div>
<div className="about-footer">
<div className="github-capsules" style={{ display: 'flex', gap: '12px', justifyContent: 'center', marginBottom: '16px' }}>
<button
className="btn btn-secondary"
style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 16px', borderRadius: '20px' }}
onClick={() => window.electronAPI.shell.openExternal('https://github.com/ILoveBingLu/miyu')}
>
<Github size={16} />
<span> CipherTalk</span>
</button>
<button
className="btn btn-secondary"
style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 16px', borderRadius: '20px' }}
onClick={() => window.electronAPI.shell.openExternal('https://github.com/hicccc77/WeFlow')}
>
<Github size={16} />
<span>WeFlow</span>
</button>
</div>
<p className="about-warning" style={{ color: '#ff4d4f', fontWeight: 500, marginBottom: '20px' }}>
西
</p>
<div className="about-links">
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.shell.openExternal('https://miyu.aiqji.com') }}></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">© {new Date().getFullYear()} -CipherTalk. All rights reserved.</p>
</div>
</div>
)
return (
<div className="settings-page">
{/* 动态粒子背景 */}
<div className="bg-particles">
{[...Array(15)].map((_, i) => (
<div key={i} className="particle" />
))}
</div>
{message && <div className={`message-toast ${message.success ? 'success' : 'error'}`}>{message.text}</div>}
{/* 清除确认对话框 */}
{showClearDialog && (
<div className="clear-dialog-overlay">
<div className="clear-dialog">
<h3>{showClearDialog.title}</h3>
<p>{showClearDialog.message}</p>
<div className="dialog-actions">
<button
className="btn btn-danger"
onClick={confirmClear}
>
</button>
<button
className="btn btn-secondary dialog-cancel"
onClick={() => setShowClearDialog(null)}
>
</button>
</div>
</div>
</div>
)}
<div className="settings-tabs">
{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 === 'database' && renderDatabaseTab()}
{activeTab === 'security' && renderSecurityTab()}
{activeTab === 'stt' && renderSttTab()}
{activeTab === 'ai' && (
<AISummarySettings
provider={aiProvider}
setProvider={setAiProviderState}
apiKey={aiApiKey}
setApiKey={setAiApiKeyState}
model={aiModel}
setModel={setAiModelState}
defaultTimeRange={aiDefaultTimeRange}
setDefaultTimeRange={setAiDefaultTimeRangeState}
summaryDetail={aiSummaryDetail}
setSummaryDetail={setAiSummaryDetailState}
systemPromptPreset={aiSystemPromptPreset}
setSystemPromptPreset={setAiSystemPromptPresetState}
customSystemPrompt={aiCustomSystemPrompt}
setCustomSystemPrompt={setAiCustomSystemPromptState}
enableThinking={aiEnableThinking}
setEnableThinking={setAiEnableThinkingState}
messageLimit={aiMessageLimit}
setMessageLimit={setAiMessageLimitState}
showMessage={showMessage}
/>
)}
{activeTab === 'data' && renderDataManagementTab()}
{activeTab === 'activation' && renderActivationTab()}
{activeTab === 'about' && renderAboutTab()}
</div>
{/* 悬浮保存按钮 */}
<button
className={`floating-save-btn ${hasUnsavedChanges ? 'has-changes' : ''}`}
onClick={handleSaveConfig}
disabled={isLoading}
title={hasUnsavedChanges ? '有未保存的更改,点击保存' : '保存配置'}
>
<Save size={20} />
</button>
</div>
)
}
export default SettingsPage