feat: 一些更新

This commit is contained in:
cc
2026-01-29 20:41:12 +08:00
parent ff1bc279f2
commit b9fa0cc215
17 changed files with 875 additions and 232 deletions

View File

@@ -52,7 +52,7 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
- 本地解密与数据库管理
> [!NOTE]
> ⚠️ 本工具仅适配微信 **4.0 及以上**版本,请确保你的微信版本符合要求
> 本工具仅适配微信 **4.0 及以上**版本,请确保你的微信版本符合要求
## 快速开始

View File

@@ -9,12 +9,12 @@ interface ConfigSchema {
imageXorKey: number
imageAesKey: string
wxidConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }>
// 缓存相关
cachePath: string
lastOpenedDb: string
lastSession: string
// 界面相关
theme: 'light' | 'dark' | 'system'
themeId: string
@@ -27,6 +27,11 @@ interface ConfigSchema {
autoTranscribeVoice: boolean
transcribeLanguages: string[]
exportDefaultConcurrency: number
// 安全相关
authEnabled: boolean
authPassword: string // SHA-256 hash
authUseHello: boolean
}
export class ConfigService {
@@ -56,7 +61,11 @@ export class ConfigService {
whisperDownloadSource: 'tsinghua',
autoTranscribeVoice: false,
transcribeLanguages: ['zh'],
exportDefaultConcurrency: 2
exportDefaultConcurrency: 2,
authEnabled: false,
authPassword: '',
authUseHello: false
}
})
}

View File

@@ -292,7 +292,7 @@ class ExportService {
extBuffer = Buffer.from(extBuffer, 'base64')
} else {
// 默认尝试hex
console.log('⚠️ 无法判断编码格式默认尝试hex')
console.log(' 无法判断编码格式默认尝试hex')
try {
extBuffer = Buffer.from(extBuffer, 'hex')
} catch (e) {

View File

@@ -47,11 +47,11 @@ ManifestDPIAware true
DetailPrint "Visual C++ Redistributable 安装成功"
MessageBox MB_OK|MB_ICONINFORMATION "Visual C++ 运行库安装成功!"
${Else}
MessageBox MB_OK|MB_ICONEXCLAMATION "Visual C++ 运行库安装失败,可能需要手动安装。"
MessageBox MB_OK|MB_ICONEXCLAMATION "Visual C++ 运行库安装失败,可能需要手动安装。"
${EndIf}
Delete "$TEMP\vc_redist.x64.exe"
${Else}
MessageBox MB_OK|MB_ICONEXCLAMATION "下载失败:$0$\n$\n可以稍后手动下载安装 Visual C++ Redistributable。"
MessageBox MB_OK|MB_ICONEXCLAMATION "下载失败:$0$\n$\n可以稍后手动下载安装 Visual C++ Redistributable。"
${EndIf}
Goto doneVC

View File

@@ -27,6 +27,7 @@ import './App.scss'
import UpdateDialog from './components/UpdateDialog'
import UpdateProgressCapsule from './components/UpdateProgressCapsule'
import LockScreen from './components/LockScreen'
function App() {
const navigate = useNavigate()
@@ -50,6 +51,10 @@ function App() {
const isVideoPlayerWindow = location.pathname === '/video-player-window'
const [themeHydrated, setThemeHydrated] = useState(false)
// 锁定状态
const [isLocked, setIsLocked] = useState(false)
const [lockAvatar, setLockAvatar] = useState('')
// 协议同意状态
const [showAgreement, setShowAgreement] = useState(false)
const [agreementChecked, setAgreementChecked] = useState(false)
@@ -250,6 +255,28 @@ function App() {
autoConnect()
}, [isAgreementWindow, isOnboardingWindow, navigate, setDbConnected])
// 检查应用锁
useEffect(() => {
if (isAgreementWindow || isOnboardingWindow || isVideoPlayerWindow) return
const checkLock = async () => {
const enabled = await configService.getAuthEnabled()
if (enabled) {
setIsLocked(true)
// 尝试获取头像
try {
const result = await window.electronAPI.chat.getMyAvatarUrl()
if (result && result.success && result.avatarUrl) {
setLockAvatar(result.avatarUrl)
}
} catch (e) {
console.error('获取锁屏头像失败', e)
}
}
}
checkLock()
}, [isAgreementWindow, isOnboardingWindow, isVideoPlayerWindow])
// 独立协议窗口
if (isAgreementWindow) {
return <AgreementPage />
@@ -267,6 +294,12 @@ function App() {
// 主窗口 - 完整布局
return (
<div className="app-container">
{isLocked && (
<LockScreen
onUnlock={() => setIsLocked(false)}
avatar={lockAvatar}
/>
)}
<TitleBar />
{/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */}
@@ -293,13 +326,13 @@ function App() {
</div>
<div className="agreement-text">
<h4>1. </h4>
<p></p>
<p></p>
<h4>2. 使</h4>
<p>使使</p>
<p>使使</p>
<h4>3. </h4>
<p>使使</p>
<p>使使</p>
<h4>4. </h4>
<p></p>

View File

@@ -0,0 +1,166 @@
.lock-screen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: var(--bg-primary);
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
user-select: none;
-webkit-app-region: drag;
.lock-content {
display: flex;
flex-direction: column;
align-items: center;
width: 320px;
-webkit-app-region: no-drag;
animation: fadeIn 0.5s ease-out;
.lock-avatar {
width: 100px;
height: 100px;
border-radius: 50%;
margin-bottom: 24px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border: 4px solid var(--bg-total);
background-color: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
}
.lock-title {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 32px;
}
.lock-form {
width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
.input-group {
position: relative;
width: 100%;
input {
width: 100%;
height: 48px;
padding: 0 16px;
padding-right: 48px;
border-radius: 12px;
border: 1px solid var(--border-color);
background-color: var(--bg-input);
color: var(--text-primary);
font-size: 16px;
outline: none;
transition: all 0.2s;
&:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-color-alpha);
}
}
.submit-btn {
position: absolute;
right: 8px;
top: 8px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
border: none;
background: var(--primary-color);
color: white;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.9;
}
}
}
.hello-btn {
width: 100%;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 12px;
border: 1px solid var(--border-color);
background-color: var(--bg-secondary);
color: var(--text-primary);
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:hover {
background-color: var(--bg-hover);
transform: translateY(-1px);
}
&.loading {
opacity: 0.7;
pointer-events: none;
}
}
}
.lock-error {
margin-top: 16px;
color: #ff4d4f;
font-size: 14px;
animation: shake 0.5s ease-in-out;
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
10%,
30%,
50%,
70%,
90% {
transform: translateX(-4px);
}
20%,
40%,
60%,
80% {
transform: translateX(4px);
}
}

View File

@@ -0,0 +1,149 @@
import { useState, useEffect, useRef } from 'react'
import * as configService from '../services/config'
import { ArrowRight, Fingerprint, Lock, ShieldCheck } from 'lucide-react'
import './LockScreen.scss'
interface LockScreenProps {
onUnlock: () => void
avatar?: string
}
async function sha256(message: string) {
const msgBuffer = new TextEncoder().encode(message)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
return hashHex
}
export default function LockScreen({ onUnlock, avatar }: LockScreenProps) {
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [isVerifying, setIsVerifying] = useState(false)
const [showHello, setShowHello] = useState(false)
const [helloAvailable, setHelloAvailable] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
checkHelloAvailability()
// Auto focus input
inputRef.current?.focus()
}, [])
const checkHelloAvailability = async () => {
try {
const useHello = await configService.getAuthUseHello()
if (useHello && window.PublicKeyCredential) {
// Simple check if WebAuthn is supported
const available = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
setHelloAvailable(available)
if (available) {
setShowHello(true)
verifyHello()
}
}
} catch (e) {
console.error('Failed to check Hello availability', e)
}
}
const verifyHello = async () => {
setIsVerifying(true)
setError('')
try {
// Use WebAuthn for authentication
// We use a dummy challenge because we are just verifying local presence
const challenge = new Uint8Array(32)
window.crypto.getRandomValues(challenge)
const credential = await navigator.credentials.get({
publicKey: {
challenge,
rpId: window.location.hostname, // 'localhost' or empty for file://
userVerification: 'required',
}
})
if (credential) {
onUnlock()
}
} catch (e: any) {
// NotAllowedError is common if user cancels
if (e.name !== 'NotAllowedError') {
console.error('Hello verification failed', e)
}
} finally {
setIsVerifying(false)
}
}
const handlePasswordSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
if (!password) return
setIsVerifying(true)
setError('')
try {
const storedHash = await configService.getAuthPassword()
const inputHash = await sha256(password)
if (inputHash === storedHash) {
onUnlock()
} else {
setError('密码错误')
setPassword('')
}
} catch (e) {
setError('验证失败')
} finally {
setIsVerifying(false)
}
}
return (
<div className="lock-screen">
<div className="lock-content">
<div className="lock-avatar">
{avatar ? (
<img src={avatar} alt="User" style={{ width: '100%', height: '100%', borderRadius: '50%' }} />
) : (
<Lock size={40} />
)}
</div>
<h2 className="lock-title">WeFlow </h2>
<form className="lock-form" onSubmit={handlePasswordSubmit}>
<div className="input-group">
<input
ref={inputRef}
type="password"
placeholder="输入应用密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isVerifying}
/>
<button type="submit" className="submit-btn" disabled={!password || isVerifying}>
<ArrowRight size={18} />
</button>
</div>
{helloAvailable && (
<button
type="button"
className={`hello-btn ${isVerifying ? 'loading' : ''}`}
onClick={verifyHello}
>
<Fingerprint size={20} />
{isVerifying ? '验证中...' : '使用 Windows Hello 解锁'}
</button>
)}
</form>
{error && <div className="lock-error">{error}</div>}
</div>
</div>
)
}

View File

@@ -9,40 +9,40 @@ function AgreementPage() {
<div className="agreement-content">
{/* 协议内容 - 请替换为完整的协议文本 */}
<h2></h2>
<h3></h3>
<p>使WeFlowWeFlow使使</p>
<p>使WeFlowWeFlow使使</p>
<h3></h3>
<p>WeFlow是一款本地化的微信聊天记录查看与分析工具</p>
<h3>使</h3>
<p>1. 使</p>
<p>2. </p>
<p>3. </p>
<h3></h3>
<p>1. "现状"</p>
<p>2. 使使</p>
<p>3. </p>
<h3></h3>
<p></p>
<h2></h2>
<h3></h3>
<p></p>
<h3></h3>
<p></p>
<p></p>
<h3></h3>
<p>访</p>
<h3></h3>
<p>广</p>
<p className="agreement-footer-text">20251</p>
</div>
</div>

View File

@@ -34,8 +34,8 @@ function AnalyticsWelcomePage() {
</div>
<h1></h1>
<p>
WeFlow <br />
WeFlow <br />
</p>
<div className="action-cards">

View File

@@ -338,61 +338,33 @@
}
}
.time-options {
display: flex;
flex-direction: column;
gap: 12px;
}
.checkbox-item {
.time-range-picker-item {
display: flex;
align-items: center;
gap: 10px;
justify-content: space-between;
padding: 14px 16px;
cursor: pointer;
font-size: 14px;
color: var(--text-primary);
input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--primary);
cursor: pointer;
}
svg {
color: var(--text-secondary);
}
&.main-toggle {
padding: 12px 16px;
background: var(--bg-secondary);
border-radius: 10px;
}
}
.date-range {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: var(--bg-secondary);
border-radius: 10px;
font-size: 14px;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
transition: background 0.2s;
background: transparent;
&:hover {
background: var(--bg-hover);
}
svg {
color: var(--text-tertiary);
flex-shrink: 0;
.time-picker-info {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: var(--text-primary);
svg {
color: var(--primary);
}
}
span {
flex: 1;
svg {
color: var(--text-tertiary);
}
}
@@ -1184,50 +1156,4 @@
color: var(--text-tertiary);
}
// Switch 开关样式
.switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
flex-shrink: 0;
input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--bg-tertiary);
transition: 0.3s;
border-radius: 24px;
&::before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
}
input:checked+.slider {
background-color: var(--primary);
}
input:checked+.slider::before {
transform: translateX(20px);
}
}
// 全局样式已在 main.scss 中定义

View File

@@ -537,21 +537,34 @@ function ExportPage() {
<div className="setting-section">
<h3></h3>
<div className="time-options">
<label className="checkbox-item">
<input
type="checkbox"
checked={options.useAllTime}
onChange={e => setOptions({ ...options, useAllTime: e.target.checked })}
/>
<span></span>
</label>
{!options.useAllTime && options.dateRange && (
<div className="date-range" onClick={() => setShowDatePicker(true)}>
<Calendar size={16} />
<span>{formatDate(options.dateRange.start)} - {formatDate(options.dateRange.end)}</span>
<ChevronDown size={14} />
<p className="setting-subtitle"></p>
<div className="media-options-card">
<div className="media-switch-row">
<div className="media-switch-info">
<span className="media-switch-title"></span>
<span className="media-switch-desc"></span>
</div>
<label className="switch">
<input
type="checkbox"
checked={options.useAllTime}
onChange={e => setOptions({ ...options, useAllTime: e.target.checked })}
/>
<span className="switch-slider"></span>
</label>
</div>
{!options.useAllTime && options.dateRange && (
<>
<div className="media-option-divider"></div>
<div className="time-range-picker-item" onClick={() => setShowDatePicker(true)}>
<div className="time-picker-info">
<Calendar size={16} />
<span>{formatDate(options.dateRange.start)} - {formatDate(options.dateRange.end)}</span>
</div>
<ChevronDown size={14} />
</div>
</>
)}
</div>
</div>
@@ -609,7 +622,7 @@ function ExportPage() {
checked={options.exportMedia}
onChange={e => setOptions({ ...options, exportMedia: e.target.checked })}
/>
<span className="slider"></span>
<span className="switch-slider"></span>
</label>
</div>
@@ -689,7 +702,7 @@ function ExportPage() {
checked={options.exportAvatars}
onChange={e => setOptions({ ...options, exportAvatars: e.target.checked })}
/>
<span className="slider"></span>
<span className="switch-slider"></span>
</label>
</div>
</div>

View File

@@ -603,54 +603,7 @@
}
}
.switch {
position: relative;
width: 46px;
height: 24px;
display: inline-block;
user-select: none;
}
.switch-input {
opacity: 0;
width: 0;
height: 0;
}
.switch-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 999px;
transition: all 0.2s ease;
}
.switch-slider::before {
content: '';
position: absolute;
height: 18px;
width: 18px;
left: 3px;
top: 2px;
background: var(--text-tertiary);
border-radius: 50%;
transition: all 0.2s ease;
}
.switch-input:checked+.switch-slider {
background: var(--primary);
border-color: var(--primary);
}
.switch-input:checked+.switch-slider::before {
transform: translateX(22px);
background: #ffffff;
}
// 全局样式已在 main.scss 中定义
.log-actions {
display: flex;
@@ -1311,4 +1264,4 @@
border-top: 1px solid var(--border-primary);
display: flex;
justify-content: flex-end;
}
}

View File

@@ -8,11 +8,12 @@ import * as configService from '../services/config'
import {
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
RotateCcw, Trash2, Save, Plug, Check, Sun, Moon,
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic,
ShieldCheck, Fingerprint, Lock, KeyRound
} from 'lucide-react'
import './SettingsPage.scss'
type SettingsTab = 'appearance' | 'database' | 'whisper' | 'export' | 'cache' | 'about'
type SettingsTab = 'appearance' | 'database' | 'whisper' | 'export' | 'cache' | 'security' | 'about'
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
{ id: 'appearance', label: '外观', icon: Palette },
@@ -20,6 +21,7 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
{ id: 'whisper', label: '语音识别模型', icon: Mic },
{ id: 'export', label: '导出', icon: Download },
{ id: 'cache', label: '缓存', icon: HardDrive },
{ id: 'security', label: '安全', icon: ShieldCheck },
{ id: 'about', label: '关于', icon: Info }
]
@@ -95,8 +97,31 @@ function SettingsPage() {
const [isClearingImageCache, setIsClearingImageCache] = useState(false)
const [isClearingAllCache, setIsClearingAllCache] = useState(false)
// 安全设置 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 [isSettingHello, setIsSettingHello] = useState(false)
const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache
// 检查 Hello 可用性
useEffect(() => {
if (window.PublicKeyCredential) {
void PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then(setHelloAvailable)
}
}, [])
async function sha256(message: string) {
const msgBuffer = new TextEncoder().encode(message)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
return hashHex
}
useEffect(() => {
loadConfig()
loadAppVersion()
@@ -154,6 +179,11 @@ function SettingsPage() {
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
const savedExportDefaultConcurrency = await configService.getExportDefaultConcurrency()
const savedAuthEnabled = await configService.getAuthEnabled()
const savedAuthUseHello = await configService.getAuthUseHello()
setAuthEnabled(savedAuthEnabled)
setAuthUseHello(savedAuthUseHello)
if (savedPath) setDbPath(savedPath)
if (savedWxid) setWxid(savedWxid)
if (savedCachePath) setCachePath(savedCachePath)
@@ -191,7 +221,7 @@ function SettingsPage() {
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
} catch (e) {
} catch (e: any) {
console.error('加载配置失败:', e)
}
}
@@ -217,7 +247,7 @@ function SettingsPage() {
try {
const version = await window.electronAPI.app.getVersion()
setAppVersion(version)
} catch (e) {
} catch (e: any) {
console.error('获取版本号失败:', e)
}
}
@@ -256,7 +286,7 @@ function SettingsPage() {
} else {
showMessage('当前已是最新版', true)
}
} catch (e) {
} catch (e: any) {
showMessage(`检查更新失败: ${e}`, false)
} finally {
setIsCheckingUpdate(false)
@@ -271,7 +301,7 @@ function SettingsPage() {
try {
showMessage('正在下载更新...', true)
await window.electronAPI.app.downloadAndInstall()
} catch (e) {
} catch (e: any) {
showMessage(`更新失败: ${e}`, false)
setIsDownloading(false)
}
@@ -366,7 +396,7 @@ function SettingsPage() {
if (!result.success && result.error) {
showMessage(result.error, false)
}
} catch (e) {
} catch (e: any) {
showMessage(`切换账号后重新连接失败: ${e}`, false)
setDbConnected(false)
}
@@ -403,7 +433,7 @@ function SettingsPage() {
} else {
showMessage(result.error || '未能自动检测到数据库目录', false)
}
} catch (e) {
} catch (e: any) {
showMessage(`自动检测失败: ${e}`, false)
} finally {
setIsDetectingPath(false)
@@ -417,7 +447,7 @@ function SettingsPage() {
setDbPath(result.filePaths[0])
showMessage('已选择数据库目录', true)
}
} catch (e) {
} catch (e: any) {
showMessage('选择目录失败', false)
}
}
@@ -445,7 +475,7 @@ function SettingsPage() {
} else {
if (!silent) showMessage('未检测到账号目录,请检查路径', false)
}
} catch (e) {
} catch (e: any) {
if (!silent) showMessage(`扫描失败: ${e}`, false)
}
}
@@ -461,7 +491,7 @@ function SettingsPage() {
setCachePath(result.filePaths[0])
showMessage('已选择缓存目录', true)
}
} catch (e) {
} catch (e: any) {
showMessage('选择目录失败', false)
}
}
@@ -477,7 +507,7 @@ function SettingsPage() {
await configService.setWhisperModelDir(dir)
showMessage('已选择 Whisper 模型目录', true)
}
} catch (e) {
} catch (e: any) {
showMessage('选择目录失败', false)
}
}
@@ -501,7 +531,7 @@ function SettingsPage() {
} else {
showMessage(result.error || '模型下载失败', false)
}
} catch (e) {
} catch (e: any) {
showMessage(`模型下载失败: ${e}`, false)
} finally {
setIsWhisperDownloading(false)
@@ -533,7 +563,7 @@ function SettingsPage() {
showMessage(result.error || '自动获取密钥失败', false)
}
}
} catch (e) {
} catch (e: any) {
showMessage(`自动获取密钥失败: ${e}`, false)
} finally {
setIsFetchingDbKey(false)
@@ -566,7 +596,7 @@ function SettingsPage() {
} else {
showMessage(result.error || '自动获取图片密钥失败', false)
}
} catch (e) {
} catch (e: any) {
showMessage(`自动获取图片密钥失败: ${e}`, false)
} finally {
setIsFetchingImageKey(false)
@@ -589,7 +619,7 @@ function SettingsPage() {
} else {
showMessage(result.error || '连接测试失败', false)
}
} catch (e) {
} catch (e: any) {
showMessage(`连接测试失败: ${e}`, false)
} finally {
setIsTesting(false)
@@ -624,8 +654,14 @@ function SettingsPage() {
await configService.setOnboardingDone(true)
// 保存按钮只负责持久化配置,不做连接测试/重连,避免影响聊天页的活动连接
// 保存安全配置
// 注意:这里只处理开关,密码修改是实时生效的(在 renderSecurityTab 里处理)
await configService.setAuthEnabled(authEnabled)
await configService.setAuthUseHello(authUseHello)
showMessage('配置保存成功', true)
} catch (e) {
} catch (e: any) {
showMessage(`保存配置失败: ${e}`, false)
} finally {
setIsLoadingState(false)
@@ -657,7 +693,7 @@ function SettingsPage() {
setIsWhisperDownloading(false)
setDbConnected(false)
await window.electronAPI.window.openOnboardingWindow()
} catch (e) {
} catch (e: any) {
showMessage(`清除配置失败: ${e}`, false)
} finally {
setIsLoadingState(false)
@@ -669,7 +705,7 @@ function SettingsPage() {
try {
const logPath = await window.electronAPI.log.getPath()
await window.electronAPI.shell.openPath(logPath)
} catch (e) {
} catch (e: any) {
showMessage(`打开日志失败: ${e}`, false)
}
}
@@ -683,7 +719,7 @@ function SettingsPage() {
}
await navigator.clipboard.writeText(result.content || '')
showMessage('日志已复制到剪贴板', true)
} catch (e) {
} catch (e: any) {
showMessage(`复制日志失败: ${e}`, false)
}
}
@@ -699,7 +735,7 @@ function SettingsPage() {
} else {
showMessage(`清除分析缓存失败: ${result.error || '未知错误'}`, false)
}
} catch (e) {
} catch (e: any) {
showMessage(`清除分析缓存失败: ${e}`, false)
} finally {
setIsClearingAnalyticsCache(false)
@@ -716,7 +752,7 @@ function SettingsPage() {
} else {
showMessage(`清除图片缓存失败: ${result.error || '未知错误'}`, false)
}
} catch (e) {
} catch (e: any) {
showMessage(`清除图片缓存失败: ${e}`, false)
} finally {
setIsClearingImageCache(false)
@@ -734,7 +770,7 @@ function SettingsPage() {
} else {
showMessage(`清除所有缓存失败: ${result.error || '未知错误'}`, false)
}
} catch (e) {
} catch (e: any) {
showMessage(`清除所有缓存失败: ${e}`, false)
} finally {
setIsClearingAllCache(false)
@@ -797,7 +833,7 @@ function SettingsPage() {
<div className="form-group">
<label></label>
<span className="form-hint">xwechat_files </span>
<span className="form-hint" style={{ color: '#ff6b6b' }}> --</span>
<span className="form-hint" style={{ color: '#ff6b6b' }}> --</span>
<input type="text" placeholder="例如: C:\Users\xxx\Documents\xwechat_files" value={dbPath} onChange={(e) => setDbPath(e.target.value)} />
<div className="btn-row">
<button className="btn btn-primary" onClick={handleAutoDetectPath} disabled={isDetectingPath}>
@@ -1210,6 +1246,129 @@ function SettingsPage() {
</div>
)
const handleSetupHello = async () => {
setIsSettingHello(true)
try {
const challenge = new Uint8Array(32)
window.crypto.getRandomValues(challenge)
const credential = await navigator.credentials.create({
publicKey: {
challenge,
rp: { name: 'WeFlow', id: window.location.hostname },
user: { id: new Uint8Array([1]), name: 'user', displayName: 'User' },
pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
authenticatorSelection: { userVerification: 'required' },
timeout: 60000
}
})
if (credential) {
setAuthUseHello(true)
await configService.setAuthUseHello(true)
showMessage('Windows Hello 设置成功', true)
}
} catch (e: any) {
if (e.name !== 'NotAllowedError') {
showMessage(`Windows Hello 设置失败: ${e.message}`, false)
}
} finally {
setIsSettingHello(false)
}
}
const handleUpdatePassword = async () => {
if (!newPassword || newPassword !== confirmPassword) {
showMessage('两次密码不一致', false)
return
}
// 简单的保存逻辑,实际上应该先验证旧密码,但为了简化流程,这里直接允许覆盖
// 因为能进入设置页面说明已经解锁了
try {
const hash = await sha256(newPassword)
await configService.setAuthPassword(hash)
await configService.setAuthEnabled(true)
setAuthEnabled(true)
setNewPassword('')
setConfirmPassword('')
showMessage('密码已更新', true)
} catch (e: any) {
showMessage('密码更新失败', false)
}
}
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"></span>
</div>
<label className="switch">
<input type="checkbox" checked={authEnabled} onChange={(e) => setAuthEnabled(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 style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 10 }}>
<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}></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>
{!helloAvailable && <div className="form-hint warning" style={{ color: '#ff4d4f' }}> Windows Hello</div>}
</div>
<div>
{authUseHello ? (
<button className="btn btn-secondary btn-sm" onClick={() => setAuthUseHello(false)}></button>
) : (
<button
className="btn btn-secondary btn-sm"
onClick={handleSetupHello}
disabled={!helloAvailable || isSettingHello}
>
{isSettingHello ? '设置中...' : '开启与设置'}
</button>
)}
</div>
</div>
</div>
</div>
)
const renderAboutTab = () => (
<div className="tab-content about-tab">
<div className="about-card">
@@ -1321,6 +1480,7 @@ function SettingsPage() {
{activeTab === 'whisper' && renderWhisperTab()}
{activeTab === 'export' && renderExportTab()}
{activeTab === 'cache' && renderCacheTab()}
{activeTab === 'security' && renderSecurityTab()}
{activeTab === 'about' && renderAboutTab()}
</div>

View File

@@ -112,7 +112,6 @@
-webkit-app-region: drag;
[data-mode="dark"] & {
background: #18181b;
border-right-color: rgba(255, 255, 255, 0.08);
}
}
@@ -152,7 +151,7 @@
margin-top: 2px;
[data-mode="dark"] .welcome-sidebar & {
color: rgba(255, 255, 255, 0.45);
color: rgba(255, 255, 255, 0.6); // 稍微调亮一点
}
}
@@ -188,7 +187,7 @@
border-radius: 12px;
[data-mode="dark"] .welcome-sidebar & {
opacity: 0.7;
opacity: 0.75; // 整体调亮一点原来是0.7
}
&.active,
@@ -236,8 +235,8 @@
transition: all 0.3s ease;
[data-mode="dark"] .welcome-sidebar & {
border-color: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.2); // 稍微调亮边框
background: rgba(255, 255, 255, 0.05);
}
.nav-item.active & {
@@ -281,7 +280,7 @@
color: #1a1a1a;
[data-mode="dark"] .welcome-sidebar & {
color: #ffffff;
color: rgba(255, 255, 255, 0.9); // 提高非活动标题亮度
}
.nav-item.active & {
@@ -299,7 +298,8 @@
}
.nav-item.active & {
color: rgba(255, 255, 255, 0.85);
color: #ffffff; // 活动描述使用纯白
font-weight: 500;
}
}
@@ -315,7 +315,7 @@
border-top: 1px dashed var(--border-color);
[data-mode="dark"] .welcome-sidebar & {
color: rgba(255, 255, 255, 0.5);
color: rgba(255, 255, 255, 0.65); // 提高底部文字亮度
border-top-color: rgba(255, 255, 255, 0.1);
}

View File

@@ -15,7 +15,8 @@ const steps = [
{ id: 'db', title: '数据库目录', desc: '定位 xwechat_files 目录' },
{ id: 'cache', title: '缓存目录', desc: '设置本地缓存存储位置(可选)' },
{ id: 'key', title: '解密密钥', desc: '获取密钥与自动识别账号' },
{ id: 'image', title: '图片密钥', desc: '获取 XOR 与 AES 密钥' }
{ id: 'image', title: '图片密钥', desc: '获取 XOR 与 AES 密钥' },
{ id: 'security', title: '安全防护', desc: '保护你的数据' }
]
interface WelcomePageProps {
@@ -46,6 +47,64 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const [imageKeyStatus, setImageKeyStatus] = useState('')
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
// 安全相关 state
const [enableAuth, setEnableAuth] = useState(false)
const [authPassword, setAuthPassword] = useState('')
const [authConfirmPassword, setAuthConfirmPassword] = useState('')
const [enableHello, setEnableHello] = useState(false)
const [helloAvailable, setHelloAvailable] = useState(false)
const [isSettingHello, setIsSettingHello] = useState(false)
// 检查 Hello 可用性
useEffect(() => {
if (window.PublicKeyCredential) {
void PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then(setHelloAvailable)
}
}, [])
async function sha256(message: string) {
const msgBuffer = new TextEncoder().encode(message)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
return hashHex
}
const handleSetupHello = async () => {
setIsSettingHello(true)
try {
// 注册凭证 (WebAuthn)
const challenge = new Uint8Array(32)
window.crypto.getRandomValues(challenge)
const credential = await navigator.credentials.create({
publicKey: {
challenge,
rp: { name: 'WeFlow', id: window.location.hostname },
user: {
id: new Uint8Array([1]),
name: 'user',
displayName: 'User'
},
pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
authenticatorSelection: { userVerification: 'required' },
timeout: 60000
}
})
if (credential) {
setEnableHello(true)
// 成功提示?
}
} catch (e: any) {
if (e.name !== 'NotAllowedError') {
setError('Windows Hello 设置失败: ' + e.message)
}
} finally {
setIsSettingHello(false)
}
}
useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
setDbKeyStatus(payload.message)
@@ -227,6 +286,12 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
if (currentStep.id === 'cache') return true
if (currentStep.id === 'key') return decryptKey.length === 64 && Boolean(wxid)
if (currentStep.id === 'image') return true
if (currentStep.id === 'security') {
if (enableAuth) {
return authPassword.length > 0 && authPassword === authConfirmPassword
}
return true
}
return false
}
@@ -277,6 +342,15 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
imageXorKey: typeof parsedXorKey === 'number' && !Number.isNaN(parsedXorKey) ? parsedXorKey : 0,
imageAesKey
})
// 保存安全配置
if (enableAuth && authPassword) {
const hash = await sha256(authPassword)
await configService.setAuthEnabled(true)
await configService.setAuthPassword(hash)
await configService.setAuthUseHello(enableHello)
}
await configService.setOnboardingDone(true)
setDbConnected(true, dbPath)
@@ -450,7 +524,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
<div className="field-hint">--</div>
<div className="field-hint warning">
</div>
</div>
)}
@@ -525,6 +599,74 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
</div>
)}
{currentStep.id === 'security' && (
<div className="form-group">
<div className="security-toggle-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div className="toggle-info">
<label className="field-label" style={{ marginBottom: 0 }}></label>
<div className="field-hint"></div>
</div>
<label className="switch">
<input type="checkbox" checked={enableAuth} onChange={e => setEnableAuth(e.target.checked)} />
<span className="switch-slider" />
</label>
</div>
{enableAuth && (
<div className="security-settings" style={{ marginTop: 20, padding: 16, backgroundColor: 'var(--bg-secondary)', borderRadius: 8 }}>
<div className="form-group">
<label className="field-label"></label>
<input
type="password"
className="field-input"
placeholder="请输入密码"
value={authPassword}
onChange={e => setAuthPassword(e.target.value)}
/>
</div>
<div className="form-group">
<label className="field-label"></label>
<input
type="password"
className="field-input"
placeholder="请再次输入密码"
value={authConfirmPassword}
onChange={e => setAuthConfirmPassword(e.target.value)}
/>
{authPassword && authConfirmPassword && authPassword !== authConfirmPassword && (
<div className="error-text" style={{ color: '#ff4d4f', fontSize: 12, marginTop: 4 }}></div>
)}
</div>
<div className="divider" style={{ margin: '20px 0', borderTop: '1px solid var(--border-color)' }}></div>
<div className="security-toggle-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="toggle-info">
<label className="field-label" style={{ marginBottom: 0 }}>Windows Hello</label>
<div className="field-hint">使 PIN </div>
</div>
{enableHello ? (
<div style={{ color: '#52c41a', display: 'flex', alignItems: 'center', gap: 6 }}>
<CheckCircle2 size={16} />
<button className="btn btn-ghost btn-sm" onClick={() => setEnableHello(false)} style={{ padding: '2px 8px', height: 24, fontSize: 12 }}></button>
</div>
) : (
<button
className="btn btn-secondary btn-sm"
disabled={!helloAvailable || isSettingHello}
onClick={handleSetupHello}
>
{isSettingHello ? '设置中...' : (helloAvailable ? '点击开启' : '不可用')}
</button>
)}
</div>
{!helloAvailable && <div className="field-hint warning"> Windows Hello PIN </div>}
</div>
)}
</div>
)}
{currentStep.id === 'image' && (
<div className="form-group">
<div className="grid-2">
@@ -564,8 +706,8 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{currentStep.id === 'intro' && (
<div className="intro-footer">
<p></p>
<p>WeFlow 访</p>
<p></p>
<p>WeFlow 访</p>
</div>
)}

View File

@@ -30,7 +30,12 @@ export const CONFIG_KEYS = {
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns',
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency'
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency',
// 安全
AUTH_ENABLED: 'authEnabled',
AUTH_PASSWORD: 'authPassword',
AUTH_USE_HELLO: 'authUseHello'
} as const
export interface WxidConfig {
@@ -365,3 +370,32 @@ export async function getExportDefaultConcurrency(): Promise<number | null> {
export async function setExportDefaultConcurrency(concurrency: number): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency)
}
// === 安全相关 ===
export async function getAuthEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AUTH_ENABLED)
return value === true
}
export async function setAuthEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AUTH_ENABLED, enabled)
}
export async function getAuthPassword(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AUTH_PASSWORD)
return (value as string) || ''
}
export async function setAuthPassword(passwordHash: string): Promise<void> {
await config.set(CONFIG_KEYS.AUTH_PASSWORD, passwordHash)
}
export async function getAuthUseHello(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AUTH_USE_HELLO)
return value === true
}
export async function setAuthUseHello(useHello: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AUTH_USE_HELLO, useHello)
}

View File

@@ -8,33 +8,33 @@
--primary-light: rgba(139, 115, 85, 0.1);
--danger: #dc3545;
--warning: #ffc107;
// 背景
--bg-primary: #F0EEE9;
--bg-secondary: rgba(255, 255, 255, 0.7);
--bg-tertiary: rgba(0, 0, 0, 0.03);
--bg-hover: rgba(0, 0, 0, 0.05);
// 文字
--text-primary: #3d3d3d;
--text-secondary: #666666;
--text-tertiary: #999999;
// 边框
--border-color: rgba(0, 0, 0, 0.08);
--border-radius: 9999px;
// 阴影
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
// 侧边栏
--sidebar-width: 220px;
// 主题渐变
--bg-gradient: linear-gradient(135deg, #F0EEE9 0%, #E8E6E1 100%);
--primary-gradient: linear-gradient(135deg, #8B7355 0%, #A68B5B 100%);
// 卡片背景
--card-bg: rgba(255, 255, 255, 0.7);
}
@@ -235,7 +235,8 @@
box-sizing: border-box;
}
html, body {
html,
body {
height: 100%;
font-family: 'HarmonyOS Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
@@ -263,7 +264,7 @@ html, body {
::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 3px;
&:hover {
background: var(--text-secondary);
}
@@ -280,20 +281,20 @@ html, body {
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
&-primary {
background: var(--primary);
color: white;
&:hover {
background: var(--primary-hover);
}
}
&-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
&:hover {
background: var(--border-color);
}
@@ -307,3 +308,60 @@ html, body {
box-shadow: var(--shadow-sm);
padding: 16px;
}
// 全局 Switch 开关样式
.switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
flex-shrink: 0;
input {
opacity: 0;
width: 0;
height: 0;
}
.switch-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--bg-tertiary);
transition: 0.3s;
border-radius: 24px;
border: 1px solid var(--border-color);
&::before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 2px;
bottom: 2px;
background-color: var(--text-tertiary);
transition: 0.3s;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
}
input:checked+.switch-slider {
background-color: var(--primary);
border-color: var(--primary);
&::before {
transform: translateX(20px);
background-color: #ffffff;
}
}
// 禁用状态
input:disabled+.switch-slider {
opacity: 0.5;
cursor: not-allowed;
}
}