mirror of
https://mirror.skon.top/github.com/ILoveBingLu/CipherTalk
synced 2026-04-30 13:51:50 +08:00
feat: 增强设置和欢迎页面的账号验证功能,优化用户体验
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -53,3 +53,4 @@ native-dlls
|
||||
MyCoolInstaller
|
||||
resources/whisper
|
||||
xkey
|
||||
skills
|
||||
|
||||
@@ -79,6 +79,27 @@ export interface AnnualReportData {
|
||||
class AnnualReportService {
|
||||
private configService: ConfigService
|
||||
private messageDbCache: Map<string, Database.Database> = new Map()
|
||||
private readonly systemAccounts = new Set([
|
||||
'medianote',
|
||||
'floatbottle',
|
||||
'qmessage',
|
||||
'qqmail',
|
||||
'fmessage',
|
||||
'weixin',
|
||||
'newsapp',
|
||||
'notification_messages',
|
||||
'weixinreminder',
|
||||
'masssendapp',
|
||||
'qqsync',
|
||||
'facebookapp',
|
||||
'feedsapp',
|
||||
'voip',
|
||||
'blogapp',
|
||||
'gmailapp',
|
||||
'linkedinplugin',
|
||||
'appbrand_notify_message',
|
||||
'appbrandcustomerservicemsg'
|
||||
])
|
||||
|
||||
constructor() {
|
||||
this.configService = new ConfigService()
|
||||
@@ -211,6 +232,32 @@ class AnnualReportService {
|
||||
return crypto.createHash('md5').update(username).digest('hex')
|
||||
}
|
||||
|
||||
private shouldExcludeAnnualSession(username: string): boolean {
|
||||
if (!username) return true
|
||||
const u = username.toLowerCase().trim()
|
||||
if (!u) return true
|
||||
|
||||
// 群聊、公众号、文件传输助手
|
||||
if (u.includes('@chatroom')) return true
|
||||
if (u.startsWith('gh_')) return true
|
||||
if (u === 'filehelper') return true
|
||||
|
||||
// 已知系统账号
|
||||
if (this.systemAccounts.has(u)) return true
|
||||
|
||||
// 邮箱/提醒类
|
||||
if (u.includes('qqmail') || u.includes('mail')) return true
|
||||
if (u.includes('reminder') || u.includes('notify')) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private extractTableHash(tableName: string): string | null {
|
||||
const match = tableName.match(/msg_([0-9a-f]{32})/i)
|
||||
if (match?.[1]) return match[1].toLowerCase()
|
||||
return null
|
||||
}
|
||||
|
||||
private hasName2IdTable(db: Database.Database): boolean {
|
||||
try {
|
||||
const result = db.prepare(
|
||||
@@ -270,7 +317,7 @@ class AnnualReportService {
|
||||
|
||||
const tables = db.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name LIKE 'Msg_%'
|
||||
WHERE type='table' AND lower(name) LIKE 'msg_%'
|
||||
`).all() as { name: string }[]
|
||||
|
||||
for (const { name: tableName } of tables) {
|
||||
@@ -345,17 +392,19 @@ class AnnualReportService {
|
||||
.map(s => s.username)
|
||||
.filter(u => {
|
||||
const uLower = u.toLowerCase()
|
||||
return uLower !== wxidLower && uLower !== cleanedWxidLower
|
||||
return uLower !== wxidLower && uLower !== cleanedWxidLower && !this.shouldExcludeAnnualSession(u)
|
||||
})
|
||||
|
||||
// 构建 hash -> username 映射
|
||||
const hashToUsername = new Map<string, string>()
|
||||
for (const username of privateUsernames) {
|
||||
hashToUsername.set(this.getTableHash(username), username)
|
||||
hashToUsername.set(this.getTableHash(username).toLowerCase(), username)
|
||||
}
|
||||
|
||||
const startTime = Math.floor(new Date(year, 0, 1).getTime() / 1000)
|
||||
const endTime = Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000)
|
||||
const isAllTime = year <= 0
|
||||
const reportYear = isAllTime ? 0 : year
|
||||
const startTime = isAllTime ? 0 : Math.floor(new Date(year, 0, 1).getTime() / 1000)
|
||||
const endTime = isAllTime ? Math.floor(Date.now() / 1000) : Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000)
|
||||
|
||||
// 统计数据
|
||||
let totalMessages = 0
|
||||
@@ -387,12 +436,13 @@ class AnnualReportService {
|
||||
|
||||
const tables = db.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name LIKE 'Msg_%'
|
||||
WHERE type='table' AND lower(name) LIKE 'msg_%'
|
||||
`).all() as { name: string }[]
|
||||
|
||||
for (const { name: tableName } of tables) {
|
||||
// 从表名提取 hash,查找对应的 sessionId
|
||||
const tableHash = tableName.replace('Msg_', '')
|
||||
const tableHash = this.extractTableHash(tableName)
|
||||
if (!tableHash) continue
|
||||
const sessionId = hashToUsername.get(tableHash)
|
||||
if (!sessionId) continue // 不是私聊表
|
||||
|
||||
@@ -803,7 +853,7 @@ class AnnualReportService {
|
||||
.map(([phrase, count]) => ({ phrase, count }))
|
||||
|
||||
const reportData: AnnualReportData = {
|
||||
year,
|
||||
year: reportYear,
|
||||
totalMessages,
|
||||
totalFriends: contactStats.size,
|
||||
coreFriends,
|
||||
|
||||
@@ -840,22 +840,44 @@ class ChatService extends EventEmitter {
|
||||
return hash
|
||||
}
|
||||
|
||||
/**
|
||||
* 从消息表名中提取会话 hash(兼容大小写与后缀)
|
||||
*/
|
||||
private extractTableHash(tableName: string): string | null {
|
||||
const match = tableName.match(/msg_([0-9a-f]{32})/i)
|
||||
if (match?.[1]) return match[1].toLowerCase()
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 在消息数据库中查找会话的消息表(带缓存)
|
||||
*/
|
||||
private findMessageTable(db: Database.Database, sessionId: string): string | null {
|
||||
try {
|
||||
const tables = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'"
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND lower(name) LIKE 'msg_%'"
|
||||
).all() as any[]
|
||||
|
||||
const hash = this.getTableNameHash(sessionId)
|
||||
const hash = this.getTableNameHash(sessionId).toLowerCase()
|
||||
|
||||
for (const table of tables) {
|
||||
const name = table.name as string
|
||||
if (name.includes(hash)) {
|
||||
|
||||
// 优先精确提取 hash 匹配
|
||||
const tableHash = this.extractTableHash(name)
|
||||
if (tableHash && tableHash === hash) {
|
||||
return name
|
||||
}
|
||||
|
||||
// 兜底兼容:历史表名规则可能不完全一致,采用大小写无关包含匹配
|
||||
if (name.toLowerCase().includes(hash)) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
if (tables.length > 0) {
|
||||
const sample = tables.slice(0, 8).map(t => t.name).join(', ')
|
||||
console.warn(`[ChatService] 未匹配到消息表: session=${sessionId}, hash=${hash}, tables=${tables.length}, sample=[${sample}]`)
|
||||
}
|
||||
} catch { }
|
||||
|
||||
@@ -3386,9 +3408,9 @@ class ChatService extends EventEmitter {
|
||||
if (!db) continue
|
||||
|
||||
// 查找所有消息表
|
||||
const tables = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'"
|
||||
).all() as any[]
|
||||
const tables = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND lower(name) LIKE 'msg_%'"
|
||||
).all() as any[]
|
||||
|
||||
for (const table of tables) {
|
||||
const tableName = table.name as string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ciphertalk",
|
||||
"version": "2.2.9",
|
||||
"version": "2.2.10",
|
||||
"description": "密语 - 微信聊天记录查看工具",
|
||||
"author": "ILoveBingLu",
|
||||
"license": "CC-BY-NC-SA-4.0",
|
||||
|
||||
@@ -2,9 +2,11 @@ import { useState, useEffect } from 'react'
|
||||
import { Calendar, Loader2, Sparkles } from 'lucide-react'
|
||||
import './AnnualReportPage.scss'
|
||||
|
||||
type YearOption = number | 'all'
|
||||
|
||||
function AnnualReportPage() {
|
||||
const [availableYears, setAvailableYears] = useState<number[]>([])
|
||||
const [selectedYear, setSelectedYear] = useState<number | null>(null)
|
||||
const [selectedYear, setSelectedYear] = useState<YearOption | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
|
||||
@@ -31,7 +33,8 @@ function AnnualReportPage() {
|
||||
if (!selectedYear) return
|
||||
setIsGenerating(true)
|
||||
try {
|
||||
await window.electronAPI.window.openAnnualReportWindow(selectedYear)
|
||||
const yearParam = selectedYear === 'all' ? 0 : selectedYear
|
||||
await window.electronAPI.window.openAnnualReportWindow(yearParam)
|
||||
} catch (e) {
|
||||
console.error('生成报告失败:', e)
|
||||
} finally {
|
||||
@@ -58,6 +61,15 @@ function AnnualReportPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const yearOptions: YearOption[] = availableYears.length > 0
|
||||
? ['all', ...availableYears]
|
||||
: []
|
||||
|
||||
const getYearLabel = (value: YearOption | null) => {
|
||||
if (!value) return ''
|
||||
return value === 'all' ? '全部时间' : `${value}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="annual-report-page">
|
||||
<Sparkles size={32} className="header-icon" />
|
||||
@@ -65,14 +77,14 @@ function AnnualReportPage() {
|
||||
<p className="page-desc">选择年份,生成你的微信聊天年度回顾</p>
|
||||
|
||||
<div className="year-grid">
|
||||
{availableYears.map(year => (
|
||||
{yearOptions.map(year => (
|
||||
<div
|
||||
key={year}
|
||||
className={`year-card ${selectedYear === year ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedYear(year)}
|
||||
>
|
||||
<span className="year-number">{year}</span>
|
||||
<span className="year-label">年</span>
|
||||
<span className="year-number">{year === 'all' ? '全部' : year}</span>
|
||||
<span className="year-label">{year === 'all' ? '时间' : '年'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -90,7 +102,7 @@ function AnnualReportPage() {
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={20} />
|
||||
<span>生成 {selectedYear} 年度报告</span>
|
||||
<span>生成 {getYearLabel(selectedYear)} 年度报告</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -361,6 +361,11 @@ function AnnualReportWindow() {
|
||||
return `${Math.round(seconds / 3600)}小时`
|
||||
}
|
||||
|
||||
const formatYearLabel = (value: number, withSuffix: boolean = true) => {
|
||||
if (value === 0) return '全部时间'
|
||||
return withSuffix ? `${value}年` : `${value}`
|
||||
}
|
||||
|
||||
// 获取可用的板块列表
|
||||
const getAvailableSections = (): SectionInfo[] => {
|
||||
if (!reportData) return []
|
||||
@@ -623,7 +628,8 @@ function AnnualReportWindow() {
|
||||
|
||||
const finalDataUrl = outputCanvas.toDataURL('image/png')
|
||||
const link = document.createElement('a')
|
||||
link.download = `${reportData?.year}年度报告.png`
|
||||
const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : ''
|
||||
link.download = `${yearFilePrefix}年度报告.png`
|
||||
link.href = finalDataUrl
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
@@ -669,22 +675,24 @@ function AnnualReportWindow() {
|
||||
// 单张图片直接下载,多张打包成 zip
|
||||
if (exportedImages.length === 1) {
|
||||
const link = document.createElement('a')
|
||||
link.download = `${reportData?.year}年度报告_${exportedImages[0].name}.png`
|
||||
const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : ''
|
||||
link.download = `${yearFilePrefix}年度报告_${exportedImages[0].name}.png`
|
||||
link.href = exportedImages[0].data
|
||||
link.click()
|
||||
} else {
|
||||
setExportProgress('正在打包...')
|
||||
const zip = new JSZip()
|
||||
const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : ''
|
||||
|
||||
for (const img of exportedImages) {
|
||||
// 从 data URL 提取 base64 数据
|
||||
const base64Data = img.data.split(',')[1]
|
||||
zip.file(`${reportData?.year}年度报告_${img.name}.png`, base64Data, { base64: true })
|
||||
zip.file(`${yearFilePrefix}年度报告_${img.name}.png`, base64Data, { base64: true })
|
||||
}
|
||||
|
||||
const blob = await zip.generateAsync({ type: 'blob' })
|
||||
const link = document.createElement('a')
|
||||
link.download = `${reportData?.year}年度报告_分模块.zip`
|
||||
link.download = `${yearFilePrefix}年度报告_分模块.zip`
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
@@ -753,6 +761,7 @@ function AnnualReportWindow() {
|
||||
}
|
||||
|
||||
const { year, totalMessages, totalFriends, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases } = reportData
|
||||
const yearTitle = formatYearLabel(year)
|
||||
const topFriend = coreFriends[0]
|
||||
const mostActive = getMostActiveTime(activityHeatmap.data)
|
||||
|
||||
@@ -855,10 +864,10 @@ function AnnualReportWindow() {
|
||||
{/* 封面 */}
|
||||
<section className="section cover-section" ref={sectionRefs.cover}>
|
||||
<div className="label-text">CipherTalk · ANNUAL REPORT</div>
|
||||
<div className="cover-year">{year}</div>
|
||||
<div className="cover-year">{year === 0 ? 'ALL' : year}</div>
|
||||
<h1 className="hero-title">微信聊天报告</h1>
|
||||
<hr className="divider" />
|
||||
<p className="hero-desc">时光匆匆,转眼又是一年<br />让我们一起回顾这一年的点点滴滴</p>
|
||||
<p className="hero-desc">时光匆匆,回望这段时间<br />让我们一起回顾你们的点点滴滴</p>
|
||||
</section>
|
||||
|
||||
{/* 年度概览 */}
|
||||
@@ -895,7 +904,7 @@ function AnnualReportWindow() {
|
||||
{/* 月度好友 */}
|
||||
<section className="section" ref={sectionRefs.monthlyFriends}>
|
||||
<div className="label-text">月度好友</div>
|
||||
<h2 className="hero-title">{year}年月度好友</h2>
|
||||
<h2 className="hero-title">{yearTitle}月度好友</h2>
|
||||
<p className="hero-desc">根据12个月的聊天习惯<br />每个月陪你最多的人</p>
|
||||
<div className="monthly-orbit">
|
||||
{monthlyTopFriends.map((m, i) => (
|
||||
@@ -1036,9 +1045,9 @@ function AnnualReportWindow() {
|
||||
{topPhrases && topPhrases.length > 0 && (
|
||||
<section className="section" ref={sectionRefs.topPhrases}>
|
||||
<div className="label-text">年度常用语</div>
|
||||
<h2 className="hero-title">你在{year}年的年度常用语</h2>
|
||||
<h2 className="hero-title">你在{yearTitle}的高频表达</h2>
|
||||
<p className="hero-desc">
|
||||
这一年,你说得最多的是:
|
||||
这段时间,你说得最多的是:
|
||||
<br />
|
||||
<span className="hl" style={{ fontSize: '20px' }}>
|
||||
{topPhrases.slice(0, 3).map(p => p.phrase).join('、')}
|
||||
@@ -1103,7 +1112,7 @@ function AnnualReportWindow() {
|
||||
我们总是在向前走,却很少有机会回头看看
|
||||
<br />愿新的一年,所有期待,皆有回声
|
||||
</p>
|
||||
<div className="ending-year">{year}</div>
|
||||
<div className="ending-year">{year === 0 ? 'ALL' : year}</div>
|
||||
<div className="ending-brand">密语-CipherTalk</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -82,6 +82,8 @@ function SettingsPage() {
|
||||
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('')
|
||||
@@ -636,21 +638,21 @@ function SettingsPage() {
|
||||
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])
|
||||
await configService.setMyWxid(wxids[0])
|
||||
showMessage(`已检测到账号:${wxids[0]}`, true)
|
||||
showMessage(`已检测到候选账号目录:${wxids[0]}(待验证)`, true)
|
||||
setWxidOptions([])
|
||||
setShowWxidDropdown(false)
|
||||
} else {
|
||||
// 多个账号,显示选择下拉框
|
||||
setWxidOptions(wxids)
|
||||
setShowWxidDropdown(true)
|
||||
showMessage(`检测到 ${wxids.length} 个账号,请选择`, true)
|
||||
showMessage(`检测到 ${wxids.length} 个候选账号目录,请选择后验证`, true)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage(`扫描失败: ${e}`, false)
|
||||
@@ -662,16 +664,41 @@ function SettingsPage() {
|
||||
// 选择 wxid
|
||||
const handleSelectWxid = async (selectedWxid: string) => {
|
||||
setWxid(selectedWxid)
|
||||
await configService.setMyWxid(selectedWxid)
|
||||
setIsAccountVerified(false)
|
||||
setShowWxidDropdown(false)
|
||||
showMessage(`已选择账号:${selectedWxid}`, true)
|
||||
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('请先输入或扫描 wxid', false); return }
|
||||
if (!wxid) { showMessage('请先选择账号目录', false); return }
|
||||
if (!isAccountVerified) { showMessage('请先验证账号目录', false); return }
|
||||
|
||||
setIsTesting(true)
|
||||
try {
|
||||
@@ -734,7 +761,7 @@ function SettingsPage() {
|
||||
await configService.setAiMessageLimit(aiMessageLimit)
|
||||
|
||||
// 如果数据库配置完整,尝试设置已连接状态(不进行耗时测试,仅标记)
|
||||
if (decryptKey && dbPath && wxid && decryptKey.length === 64) {
|
||||
if (decryptKey && dbPath && wxid && decryptKey.length === 64 && isAccountVerified) {
|
||||
setDbConnected(true, dbPath)
|
||||
}
|
||||
|
||||
@@ -1056,19 +1083,26 @@ function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>账号 wxid</label>
|
||||
<span className="form-hint">微信账号标识(只包含 db_storage 子目录的文件夹会被识别)</span>
|
||||
<label>账号目录(待验证)</label>
|
||||
<span className="form-hint">先扫描候选目录,获取密钥后再进行验证</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="例如: wxid_xxxxxx"
|
||||
placeholder="例如: wxid_xxxxxx 或其他账号目录名"
|
||||
value={wxid}
|
||||
onChange={(e) => setWxid(e.target.value)}
|
||||
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 ? '扫描中...' : '扫描 wxid'}
|
||||
<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 && (
|
||||
|
||||
@@ -39,6 +39,8 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
const [cachePath, setCachePath] = useState('')
|
||||
const [wxid, setWxid] = useState('')
|
||||
const [wxidOptions, setWxidOptions] = useState<string[]>([])
|
||||
const [isAccountVerified, setIsAccountVerified] = useState(false)
|
||||
const [isVerifyingAccount, setIsVerifyingAccount] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [isScanningWxid, setIsScanningWxid] = useState(false)
|
||||
@@ -134,10 +136,38 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
|
||||
useEffect(() => {
|
||||
setWxidOptions([])
|
||||
setIsAccountVerified(false)
|
||||
// 注意:不要清空 wxid,因为它可能是从缓存加载的
|
||||
// setWxid('')
|
||||
}, [dbPath])
|
||||
|
||||
const verifyAccountDirectory = async (candidateWxid: string, key: string, silent = false) => {
|
||||
if (!dbPath || !candidateWxid || key.length !== 64) {
|
||||
setIsAccountVerified(false)
|
||||
return false
|
||||
}
|
||||
|
||||
setIsVerifyingAccount(true)
|
||||
try {
|
||||
const result = await window.electronAPI.wcdb.testConnection(dbPath, key, candidateWxid)
|
||||
if (result.success) {
|
||||
setIsAccountVerified(true)
|
||||
if (!silent) setDbKeyStatus(`账号目录验证成功:${candidateWxid}`)
|
||||
return true
|
||||
}
|
||||
|
||||
setIsAccountVerified(false)
|
||||
if (!silent) setError(result.error || '账号目录验证失败,请重新选择')
|
||||
return false
|
||||
} catch (e) {
|
||||
setIsAccountVerified(false)
|
||||
if (!silent) setError(`账号目录验证失败: ${e}`)
|
||||
return false
|
||||
} finally {
|
||||
setIsVerifyingAccount(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存配置到缓存
|
||||
useEffect(() => {
|
||||
const config = {
|
||||
@@ -223,8 +253,9 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
try {
|
||||
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
|
||||
setWxidOptions(wxids)
|
||||
setIsAccountVerified(false)
|
||||
if (wxids.length > 0) {
|
||||
// 优先选择以 wxid_ 开头的账号
|
||||
// 密钥前仅做候选识别,默认优先 wxid_ 前缀目录
|
||||
const wxidAccount = wxids.find(id => id.startsWith('wxid_'))
|
||||
const selectedWxid = wxidAccount || wxids[0]
|
||||
setWxid(selectedWxid)
|
||||
@@ -250,16 +281,37 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
const result = await window.electronAPI.wxKey.startGetKey(wechatPath)
|
||||
if (result.success && result.key) {
|
||||
setDecryptKey(result.key)
|
||||
setDbKeyStatus('密钥获取成功,正在识别账号...')
|
||||
setDbKeyStatus('密钥获取成功,正在验证账号目录...')
|
||||
setError('')
|
||||
setShowWechatPathPrompt(false)
|
||||
|
||||
// 先尝试当前登录账号检测(强信号)
|
||||
let accountInfo: { wxid: string; dbPath: string } | null = null
|
||||
if (dbPath) {
|
||||
accountInfo = await window.electronAPI.wxKey.detectCurrentAccount(dbPath, 10)
|
||||
if (!accountInfo) {
|
||||
accountInfo = await window.electronAPI.wxKey.detectCurrentAccount(dbPath, 60)
|
||||
}
|
||||
}
|
||||
|
||||
if (accountInfo) {
|
||||
setWxid(accountInfo.wxid)
|
||||
const ok = await verifyAccountDirectory(accountInfo.wxid, result.key, true)
|
||||
if (ok) {
|
||||
setDbKeyStatus(`密钥获取成功,已验证账号目录: ${accountInfo.wxid}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const wxids = await handleScanWxid(true)
|
||||
if (wxids.length > 1) {
|
||||
setDbKeyStatus(`密钥获取成功,识别到 ${wxids.length} 个账号,请选择`)
|
||||
// 多账号时仅作为候选,等待用户选择后再验证
|
||||
setDbKeyStatus(`密钥获取成功,识别到 ${wxids.length} 个候选账号目录,请选择后验证`)
|
||||
} else if (wxids.length === 1) {
|
||||
setDbKeyStatus('密钥获取成功,已自动识别账号')
|
||||
const ok = await verifyAccountDirectory(wxids[0], result.key, true)
|
||||
setDbKeyStatus(ok ? '密钥获取成功,已自动识别并验证账号目录' : '密钥获取成功,请手动确认账号目录')
|
||||
} else {
|
||||
setDbKeyStatus('密钥获取成功')
|
||||
setDbKeyStatus('密钥获取成功,请手动选择并验证账号目录')
|
||||
}
|
||||
} else {
|
||||
if (result.needManualPath) {
|
||||
@@ -352,8 +404,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
if (currentStep.id === 'intro') return true
|
||||
if (currentStep.id === 'db') return Boolean(dbPath)
|
||||
if (currentStep.id === 'cache') return Boolean(cachePath)
|
||||
if (currentStep.id === 'key') return decryptKey.length === 64 && Boolean(wxid)
|
||||
if (currentStep.id === 'key') return decryptKey.length === 64 && Boolean(wxid)
|
||||
if (currentStep.id === 'key') return decryptKey.length === 64 && Boolean(wxid) && isAccountVerified
|
||||
if (currentStep.id === 'image') return true
|
||||
if (currentStep.id === 'security') return true
|
||||
if (currentStep.id === 'decrypt') return false // 最后一步,不能下一步
|
||||
@@ -366,7 +417,8 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
if (currentStep.id === 'cache' && !cachePath) setError('请填写缓存目录')
|
||||
if (currentStep.id === 'key') {
|
||||
if (decryptKey.length !== 64) setError('密钥长度必须为 64 个字符')
|
||||
else if (!wxid) setError('未能自动识别 wxid,请尝试重新获取或检查目录')
|
||||
else if (!wxid) setError('请先选择账号目录')
|
||||
else if (!isAccountVerified) setError('账号目录尚未验证,请先验证后继续')
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -381,7 +433,8 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
|
||||
const handleStartDecrypt = async () => {
|
||||
if (!dbPath) { setError('请先选择数据库目录'); return }
|
||||
if (!wxid) { setError('请填写微信ID'); return }
|
||||
if (!wxid) { setError('请先选择账号目录'); return }
|
||||
if (!isAccountVerified) { setError('账号目录尚未验证,请先验证'); return }
|
||||
if (!decryptKey || decryptKey.length !== 64) { setError('请填写 64 位解密密钥'); return }
|
||||
|
||||
setIsDecrypting(true)
|
||||
@@ -770,13 +823,16 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
|
||||
{currentStep.id === 'key' && (
|
||||
<div className="setup-body">
|
||||
<label className="field-label">微信账号 wxid</label>
|
||||
<label className="field-label">账号目录(待验证)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
placeholder="获取密钥后将自动填充"
|
||||
value={wxid}
|
||||
onChange={(e) => setWxid(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setWxid(e.target.value)
|
||||
setIsAccountVerified(false)
|
||||
}}
|
||||
/>
|
||||
{wxidOptions.length > 0 && (
|
||||
<div className="wxid-options">
|
||||
@@ -784,13 +840,31 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
<button
|
||||
key={id}
|
||||
className={`wxid-option ${wxid === id ? 'is-selected' : ''}`}
|
||||
onClick={() => setWxid(id)}
|
||||
onClick={async () => {
|
||||
setWxid(id)
|
||||
setIsAccountVerified(false)
|
||||
if (decryptKey.length === 64) {
|
||||
await verifyAccountDirectory(id, decryptKey)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="wxid-option-name">{id}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="button-row">
|
||||
<button
|
||||
className="btn btn-secondary btn-inline"
|
||||
onClick={() => verifyAccountDirectory(wxid, decryptKey)}
|
||||
disabled={isVerifyingAccount || !wxid || decryptKey.length !== 64}
|
||||
>
|
||||
{isVerifyingAccount ? '验证中...' : '验证账号目录'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="field-hint">
|
||||
状态:{isAccountVerified ? '✅ 已验证' : '⚠️ 未验证(密钥前只能识别候选目录)'}
|
||||
</div>
|
||||
<label className="field-label">解密密钥</label>
|
||||
<div className="field-with-toggle">
|
||||
<input
|
||||
@@ -832,7 +906,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
)}
|
||||
|
||||
{dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>}
|
||||
<div className="field-hint">获取密钥会自动启动微信并识别账号</div>
|
||||
<div className="field-hint">获取密钥会自动启动微信并识别候选账号目录</div>
|
||||
<div className="field-hint">点击自动获取后等待提示<span style={{ color: 'red' }}>hook安装成功</span>,然后登录微信即可</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -938,8 +1012,8 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
<span className="summary-value">{cachePath || '未设置'}</span>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">微信账号:</span>
|
||||
<span className="summary-value">{wxid || '未设置'}</span>
|
||||
<span className="summary-label">账号目录:</span>
|
||||
<span className="summary-value">{wxid ? `${wxid}${isAccountVerified ? '(已验证)' : '(未验证)'}` : '未设置'}</span>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">解密密钥:</span>
|
||||
|
||||
Reference in New Issue
Block a user