feat: 完善多账号连接诊断与聊天媒体导出

- 新增微信账号发现、环境诊断和分步数据库连接引导
- 支持按账号安全保存数据库密钥及快速切换账号
- 完善 WCDB 历史消息分片读取和分页状态提示
- 支持导出图片、视频和语音,提供原图优先及缩略图回退
- 更新安装指引、兼容版本说明和相关自动化测试
This commit is contained in:
Wxw-Gu
2026-08-03 10:43:14 +08:00
parent 224308f0e0
commit 08e1294e5d
37 changed files with 2011 additions and 255 deletions
+246 -18
View File
@@ -24,6 +24,7 @@ import { FirstUseWelcome } from './components/FirstUseWelcome'
import { ExportWorkspace } from './components/export/ExportWorkspace'
import { AISearchWorkspace } from './components/search/AISearchWorkspace'
import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export'
import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../shared/database-key'
import {
getMessageIdentity,
mergeMessagePages,
@@ -32,6 +33,23 @@ import {
const SIDEBAR_MIN_WIDTH = 260
const SIDEBAR_MAX_WIDTH = 380
const DATABASE_CONNECT_TIMEOUT_MS = 30_000
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs)
promise.then(
(value) => {
window.clearTimeout(timer)
resolve(value)
},
(error) => {
window.clearTimeout(timer)
reject(error)
}
)
})
}
function getDevelopmentDatabaseKey(): string {
if (!import.meta.env.DEV) return ''
@@ -202,6 +220,7 @@ function App(): React.ReactElement {
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
const [messages, setMessages] = useState<Message[]>([])
const [isMessagesLoading, setIsMessagesLoading] = useState(false)
const [messageHistoryStatus, setMessageHistoryStatus] = useState<'idle' | 'end' | 'error'>('idle')
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([])
const [contentFilter, setContentFilter] = useState('')
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
@@ -209,10 +228,16 @@ function App(): React.ReactElement {
const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal')
const [showDbKey, setShowDbKey] = useState(false)
const [dbRootInput, setDbRootInput] = useState('')
const [discoveredAccounts, setDiscoveredAccounts] = useState<WechatAccountCandidate[]>([])
const [selectedAccountId, setSelectedAccountId] = useState('')
const selectedAccount = discoveredAccounts.find((account) => account.id === selectedAccountId)
const [showMacKeyFaq, setShowMacKeyFaq] = useState(false)
const [databaseConnectionMode, setDatabaseConnectionMode] = useState<DatabaseConnectionMode>(
getDevelopmentDatabaseKey() ? 'manual' : 'automatic'
)
const [connectionGuideStep, setConnectionGuideStep] = useState<1 | 2 | 3 | 4 | 5 | 6>(1)
const [databaseEnvironment, setDatabaseEnvironment] = useState<DatabaseKeyEnvironment>()
const connectionOperationRef = React.useRef(0)
const [activePage, setActivePage] = useState<AppPage>('archive')
const [archiveJumpTime, setArchiveJumpTime] = useState<number | null>(null)
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
@@ -278,6 +303,34 @@ function App(): React.ReactElement {
})
})
}, [])
const refreshConnectionEnvironment = React.useCallback(async (): Promise<void> => {
const root = dbRootInput.trim()
try {
if (root) {
const discovery = await window.api.discoverAccounts(root)
if (!discovery.success) throw new Error(discovery.error || '账号目录识别失败')
setDiscoveredAccounts(discovery.accounts)
setSelectedAccountId(discovery.preselectedAccountId || '')
}
const environment = await window.api.getDatabaseKeyEnvironment()
setDatabaseEnvironment(environment)
setDbKeyStatus('环境检查已更新')
setDbKeyStatusKind('normal')
} catch (error) {
setDbKeyStatus(
error instanceof Error ? `环境检查失败:${error.message}` : '环境检查失败,请重试'
)
setDbKeyStatusKind('error')
}
}, [dbRootInput])
React.useEffect(() => {
void window.api
.getDatabaseKeyEnvironment()
.then(setDatabaseEnvironment)
.catch(() => undefined)
}, [])
React.useEffect(() => {
const loadAIConfig = async (): Promise<void> => {
try {
@@ -533,13 +586,26 @@ function App(): React.ReactElement {
if (active && settingsResult.settings.dbRoot) {
setDbRootInput(settingsResult.settings.dbRoot)
}
const discovery = settingsResult.settings.dbRoot
? await window.api.discoverAccounts(settingsResult.settings.dbRoot)
: { success: false, accounts: [] }
if (active && discovery.success) {
setDiscoveredAccounts(discovery.accounts)
setSelectedAccountId(discovery.preselectedAccountId || '')
}
const startupAccountRoot = discovery.success
? discovery.accounts.find((account) => account.id === discovery.preselectedAccountId)
?.accountRoot
: undefined
const autoLoginEnabled = settingsResult.settings.autoLogin
// 开发环境允许使用 VITE_DB_KEY;生产安装包只能读取目标电脑自己的 safeStorage。
const envKey = getDevelopmentDatabaseKey()
// 生产环境以及未配置开发密钥时,读取上一次保存到 safeStorage 的密钥。
let savedKey = ''
if (!envKey) {
const result = await window.api.getSavedDbKey()
const result = startupAccountRoot
? await window.api.getSavedDbKey(startupAccountRoot)
: { success: true, saved: false, encryptionAvailable: true }
if (result.success && result.key) savedKey = result.key
}
const key = envKey || savedKey
@@ -570,7 +636,11 @@ function App(): React.ReactElement {
try {
const startupCacheReady = await loadStartupCache()
setIsDatabaseConnecting(true)
const initPromise = window.api.initDb(key)
if (!startupAccountRoot) {
setBootState('login')
return
}
const initPromise = window.api.initDb(key, startupAccountRoot)
if (startupCacheReady) {
setIsAuthenticated(true)
setIsDatabaseConnected(false)
@@ -655,13 +725,36 @@ function App(): React.ReactElement {
void loadGeneratedReports()
}, [isAuthenticated, loadGeneratedReports])
const handleLogin = async (keyInput?: string): Promise<void> => {
const handleLogin = async (keyInput?: string, accountRootInput?: string): Promise<void> => {
const keyToUse = keyInput || dbKey
if (!keyToUse) return
setBootState('connecting')
let accountRoot = accountRootInput || selectedAccount?.accountRoot
if (!keyToUse || isDatabaseConnecting) return
if (!accountRoot) {
const discovery = await window.api.discoverAccounts(dbRootInput.trim())
if (!discovery.success) {
setDbKeyStatus(discovery.error || '微信数据目录不可用')
setDbKeyStatusKind('error')
return
}
if (!discovery.preselectedAccountId) {
setDiscoveredAccounts(discovery.accounts)
setDbKeyStatus('请选择要连接的微信账号')
setDbKeyStatusKind('error')
return
}
const account = discovery.accounts.find(
(candidate) => candidate.id === discovery.preselectedAccountId
)
if (!account) return
setDiscoveredAccounts(discovery.accounts)
setSelectedAccountId(account.id)
accountRoot = account.accountRoot
}
const operationId = ++connectionOperationRef.current
if (databaseConnectionMode === 'automatic') setConnectionGuideStep(6)
setIsDatabaseConnecting(true)
// 持久化用户手动指定的微信聊天文件路径,供 db:init 读取 settings.dbRoot
const trimmedRoot = dbRootInput.trim()
const trimmedRoot = accountRoot
if (trimmedRoot) {
try {
await window.api.setSettings({ dbRoot: trimmedRoot })
@@ -682,7 +775,12 @@ function App(): React.ReactElement {
detail: '正在打开 WCDB 数据库',
percent: 15
})
const result = await window.api.initDb(keyToUse)
const result = await withTimeout(
window.api.initDb(keyToUse, trimmedRoot),
DATABASE_CONNECT_TIMEOUT_MS,
'数据库连接超时,请检查数据目录后重试'
)
if (operationId !== connectionOperationRef.current) return
const success = typeof result === 'boolean' ? result : result.success
if (success) {
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
@@ -693,8 +791,9 @@ function App(): React.ReactElement {
percent: 25
})
const hasBootstrap = await loadBootstrapCache()
if (operationId !== connectionOperationRef.current) return
// 持久化手动输入的密钥,供下次启动继续使用
void window.api.saveDbKey(keyToUse).catch(() => undefined)
void window.api.saveDbKey(trimmedRoot, keyToUse).catch(() => undefined)
void window.api.getSettings().then((current) => {
if (!current.settings.autoLoginPreferenceSet) {
void window.api.setSettings({ autoLogin: true })
@@ -734,17 +833,23 @@ function App(): React.ReactElement {
}, 500)
} else {
const error = typeof result === 'boolean' ? '' : result.error
setDbKeyStatus(error || '数据库连接失败,请检查密钥和数据目录后重试')
setDbKeyStatusKind('error')
if (databaseConnectionMode === 'automatic') setConnectionGuideStep(5)
setBootState('login')
setStartupProgress(null)
alert(`Failed to open database.${error ? `\n\n${error}` : '\nCheck your key.'}`)
}
} catch (error) {
console.error(error)
setDbKeyStatus(
error instanceof Error ? `数据库连接失败:${error.message}` : '数据库连接失败,请重试'
)
setDbKeyStatusKind('error')
if (databaseConnectionMode === 'automatic') setConnectionGuideStep(5)
setBootState('login')
setStartupProgress(null)
alert('Error connecting to database')
} finally {
setIsDatabaseConnecting(false)
if (operationId === connectionOperationRef.current) setIsDatabaseConnecting(false)
}
}
@@ -864,31 +969,42 @@ function App(): React.ReactElement {
const handleAutoGetDbKey = async (): Promise<void> => {
if (isFetchingDbKey) return
const operationId = ++connectionOperationRef.current
setConnectionGuideStep(4)
setIsFetchingDbKey(true)
setDbKeyStatus('正在准备获取密钥...')
setDbKeyStatusKind('normal')
setShowMacKeyFaq(false)
try {
const result = await window.api.autoGetDbKey()
if (!selectedAccount) throw new Error('请先选择微信账号')
const result = await window.api.autoGetDbKey(selectedAccount.accountRoot)
if (operationId !== connectionOperationRef.current) return
if (!result.success || !result.key) {
setShowMacKeyFaq(result.code === 'SCAN_FAILED')
throw new Error(result.error || '获取密钥失败')
}
setDbKey(result.key)
setDatabaseConnectionMode('manual')
setConnectionGuideStep(5)
setDbKeyStatus(result.saved ? '密钥已获取并安全保存' : result.warning || '密钥已获取')
setDbKeyStatusKind(result.saved ? 'success' : 'normal')
} catch (error) {
if (operationId !== connectionOperationRef.current) return
setDbKeyStatus(error instanceof Error ? error.message : String(error))
setDbKeyStatusKind('error')
setConnectionGuideStep(3)
} finally {
setIsFetchingDbKey(false)
if (operationId === connectionOperationRef.current) setIsFetchingDbKey(false)
}
}
const handlePasteAndSaveDbKey = async (): Promise<void> => {
setShowMacKeyFaq(false)
const result = await window.api.pasteAndSaveDbKey()
if (!selectedAccount) {
setDbKeyStatus('请先选择微信账号')
setDbKeyStatusKind('error')
return
}
const result = await window.api.pasteAndSaveDbKey(selectedAccount.accountRoot)
if (result.success && result.key) {
setDbKey(result.key)
setDatabaseConnectionMode('manual')
@@ -902,7 +1018,8 @@ function App(): React.ReactElement {
const handleClearSavedDbKey = async (): Promise<void> => {
setShowMacKeyFaq(false)
const result = await window.api.clearSavedDbKey()
if (!selectedAccount) return
const result = await window.api.clearSavedDbKey(selectedAccount.accountRoot)
if (!result.success) {
setDbKeyStatus(result.error || '清除密钥失败')
setDbKeyStatusKind('error')
@@ -935,12 +1052,54 @@ function App(): React.ReactElement {
setStartupProgress(null)
}
const handleSwitchAccount = async (account: WechatAccountCandidate): Promise<void> => {
connectionOperationRef.current += 1
await window.api.disconnectDb({ closeNative: true })
setIsAuthenticated(false)
setIsDatabaseConnected(false)
setSelectedContact(null)
setMessages([])
setContacts([])
setFilteredContacts([])
setContentFilter('')
setSelfInfo(null)
setReportSourceContact(null)
setExportTasks([])
messageHistoryRef.current = []
messagesRef.current = []
selectedContactMd5Ref.current = ''
currentGroupSnapshotRef.current = null
groupMemberMetaRef.current = {}
syntheticGroupMessagesRef.current = {}
setDiscoveredAccounts((current) =>
current.some((item) => item.id === account.id) ? current : [account]
)
setSelectedAccountId(account.id)
setDbRootInput(account.accountRoot)
await window.api.setSettings({ dbRoot: account.accountRoot, imageKeyRoot: account.accountRoot })
const saved = await window.api.getSavedDbKey(account.accountRoot)
if (!saved.success || !saved.key) {
setDbKey('')
setDatabaseConnectionMode('automatic')
setBootState('login')
setConnectionGuideStep(3)
setDbKeyStatus('该账号尚无可用密钥,请为所选账号获取密钥')
setDbKeyStatusKind('normal')
return
}
setDbKey(saved.key)
setDatabaseConnectionMode('manual')
setBootState('login')
await handleLogin(saved.key, account.accountRoot)
}
const handleSelectContact = async (contact: Contact, forceLive = false): Promise<void> => {
setArchiveJumpTime(null)
setSelectedContact(contact)
selectedContactMd5Ref.current = contact.md5
currentGroupSnapshotRef.current = null
setIsMessagesLoading(true)
setMessageHistoryStatus('idle')
const cachedPage = await window.api.getCachedMessagePage(contact.md5)
const cachedMsgs = cachedPage.messages
if (selectedContactMd5Ref.current !== contact.md5) return
@@ -1052,7 +1211,7 @@ function App(): React.ReactElement {
const handleLoadOlderMessages = async (): Promise<void> => {
const contact = selectedContact
if (!contact || messagesRef.current.length === 0) return
if (!contact || messagesRef.current.length === 0 || messageHistoryStatus === 'end') return
if (messagePrefetchRef.current) await messagePrefetchRef.current
if (selectedContactMd5Ref.current !== contact.md5) return
const currentMessages = messagesRef.current
@@ -1089,6 +1248,7 @@ function App(): React.ReactElement {
limit: MESSAGE_PAGE_SIZE
})
if (selectedContactMd5Ref.current !== contact.md5) return
setMessageHistoryStatus(olderMessages.length === 0 ? 'end' : 'idle')
messageHistoryRef.current = mergeMessagePages(olderMessages, historyMessages)
setMessages((current) =>
applyGroupMemberMeta(
@@ -1098,6 +1258,7 @@ function App(): React.ReactElement {
)
} catch (error) {
console.warn('[Messages] older page load failed:', error)
if (selectedContactMd5Ref.current === contact.md5) setMessageHistoryStatus('error')
} finally {
if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false)
}
@@ -1434,6 +1595,7 @@ function App(): React.ReactElement {
contact={selectedContact}
messages={messages}
isLoadingMessages={isMessagesLoading}
messageHistoryStatus={messageHistoryStatus}
contentFilter={contentFilter}
onContentFilterChange={setContentFilter}
onRefresh={() => selectedContact && handleSelectContact(selectedContact, true)}
@@ -1561,6 +1723,7 @@ function App(): React.ReactElement {
onNotice={setReportNotice}
onOpenSettings={openSettings}
onAppearanceChange={handleAppearanceChange}
onSwitchAccount={handleSwitchAccount}
/>
)
case 'search':
@@ -1675,15 +1838,80 @@ function App(): React.ReactElement {
dbRoot={dbRootInput}
showDbKey={showDbKey}
isFetching={isFetchingDbKey}
isConnecting={isDatabaseConnecting}
guideStep={connectionGuideStep}
environment={databaseEnvironment}
accounts={discoveredAccounts}
selectedAccountId={selectedAccountId}
status={dbKeyStatus}
statusKind={dbKeyStatusKind}
showMacKeyFaq={showMacKeyFaq}
macKeyFaqUrl={MAC_KEY_FAQ_URL}
onModeChange={setDatabaseConnectionMode}
onDbKeyChange={setDbKey}
onDbRootChange={setDbRootInput}
onDbRootChange={(value) => {
setDbRootInput(value)
setDiscoveredAccounts([])
setSelectedAccountId('')
}}
onSelectAccount={(account) => {
setSelectedAccountId(account.id)
setDbKey('')
void window.api.getSavedDbKey(account.accountRoot).then((result) => {
if (result.success && result.key) setDbKey(result.key)
})
}}
onSelectDbRoot={() => {
void window.api.selectDbRoot().then((result) => {
if (!result.canceled && result.path) {
setDbRootInput(result.path)
void window.api.discoverAccounts(result.path).then((discovery) => {
if (!discovery.success) {
setDiscoveredAccounts([])
setSelectedAccountId('')
setDbKeyStatus(discovery.error || '账号目录识别失败')
setDbKeyStatusKind('error')
return
}
setDiscoveredAccounts(discovery.accounts)
setSelectedAccountId(discovery.preselectedAccountId || '')
})
}
})
}}
onToggleDbKey={() => setShowDbKey((visible) => !visible)}
onAutoGetKey={handleAutoGetDbKey}
onRefreshEnvironment={() => void refreshConnectionEnvironment()}
onGuideNext={() =>
setConnectionGuideStep((current) => (current === 1 ? 2 : current === 2 ? 3 : current))
}
onGuideBack={() =>
setConnectionGuideStep((current) =>
current === 5 ? 3 : current > 1 ? ((current - 1) as 1 | 2 | 3 | 4 | 5 | 6) : 1
)
}
onGuideCancel={() => {
connectionOperationRef.current += 1
setIsFetchingDbKey(false)
setIsDatabaseConnecting(false)
setBootState('login')
setStartupProgress(null)
setConnectionGuideStep(1)
setDbKeyStatus('已取消,可以重新检查环境')
setDbKeyStatusKind('normal')
}}
onValidateConnection={() => void handleLogin()}
onCopyDiagnostics={() => {
if (!databaseEnvironment?.diagnosticSummary) {
setDbKeyStatus('诊断信息尚未准备好,请先重新检查环境')
setDbKeyStatusKind('error')
return
}
void window.api.copyText(databaseEnvironment.diagnosticSummary).then(() => {
setDbKeyStatus('脱敏诊断摘要已复制')
setDbKeyStatusKind('success')
})
}}
onManualConnect={() => handleLogin()}
onPasteKey={handlePasteAndSaveDbKey}
onClearKey={handleClearSavedDbKey}
@@ -10,6 +10,7 @@ interface ChatWindowProps {
contact: Contact | null
messages: Message[]
isLoadingMessages?: boolean
messageHistoryStatus?: 'idle' | 'end' | 'error'
contentFilter?: string
onContentFilterChange?: (keyword: string) => void
onRefresh?: () => void
@@ -25,6 +26,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
contact,
messages,
isLoadingMessages,
messageHistoryStatus,
contentFilter,
onContentFilterChange,
onRefresh,
@@ -205,6 +207,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
messages={filteredMessages}
hiddenMessageCount={0}
isLoadingMessages={isLoadingMessages}
messageHistoryStatus={messageHistoryStatus}
isGroupChat={isGroupChat}
showAvatar={showAvatar}
listRef={messageListRef}
@@ -1,4 +1,5 @@
import React from 'react'
import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../../shared/database-key'
const GUIDE_URL =
'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md'
@@ -13,6 +14,11 @@ interface DatabaseConnectionPageProps {
dbRoot: string
showDbKey: boolean
isFetching: boolean
isConnecting: boolean
guideStep: 1 | 2 | 3 | 4 | 5 | 6
environment?: DatabaseKeyEnvironment
accounts: WechatAccountCandidate[]
selectedAccountId: string
status: string
statusKind: DatabaseConnectionStatusKind
showMacKeyFaq: boolean
@@ -20,8 +26,16 @@ interface DatabaseConnectionPageProps {
onModeChange: (mode: DatabaseConnectionMode) => void
onDbKeyChange: (value: string) => void
onDbRootChange: (value: string) => void
onSelectAccount: (account: WechatAccountCandidate) => void
onSelectDbRoot: () => void
onToggleDbKey: () => void
onAutoGetKey: () => void
onRefreshEnvironment: () => void
onGuideNext: () => void
onGuideBack: () => void
onGuideCancel: () => void
onValidateConnection: () => void
onCopyDiagnostics: () => void
onManualConnect: () => void
onPasteKey: () => void
onClearKey: () => void
@@ -92,6 +106,11 @@ export function DatabaseConnectionPage({
dbRoot,
showDbKey,
isFetching,
isConnecting,
guideStep,
environment,
accounts = [],
selectedAccountId = '',
status,
statusKind,
showMacKeyFaq,
@@ -99,8 +118,16 @@ export function DatabaseConnectionPage({
onModeChange,
onDbKeyChange,
onDbRootChange,
onSelectAccount,
onSelectDbRoot,
onToggleDbKey,
onAutoGetKey,
onRefreshEnvironment,
onGuideNext,
onGuideBack,
onGuideCancel,
onValidateConnection,
onCopyDiagnostics,
onManualConnect,
onPasteKey,
onClearKey
@@ -146,27 +173,27 @@ export function DatabaseConnectionPage({
<div className="database-login-start">
<p className="database-login-eyebrow">使</p>
<h2></h2>
<p> 3 </p>
<p></p>
<ol>
<li>
<span>1</span>
<div>
<strong></strong>
<small></small>
<strong></strong>
<small></small>
</div>
</li>
<li>
<span>2</span>
<div>
<strong></strong>
<small></small>
<strong></strong>
<small></small>
</div>
</li>
<li>
<span>3</span>
<div>
<strong></strong>
<small></small>
<strong></strong>
<small></small>
</div>
</li>
</ol>
@@ -202,6 +229,11 @@ export function DatabaseConnectionPage({
{mode === 'automatic' ? (
<div className="database-login-auto" role="tabpanel">
<div className="database-login-guide-progress" aria-label={`连接进度 ${guideStep}/6`}>
{Array.from({ length: 6 }, (_, index) => (
<span key={index} className={index + 1 <= guideStep ? 'active' : ''} />
))}
</div>
<div className={`database-login-state-card ${statusKind}`}>
<div className="database-login-state-heading">
<span className="database-login-state-icon">
@@ -209,57 +241,206 @@ export function DatabaseConnectionPage({
</span>
<div>
<strong>
{statusKind === 'error' ? '未能获取数据库密钥' : '已准备检测微信数据库'}
{statusKind === 'error'
? '当前步骤未完成'
: [
'检查本机环境',
'让微信停在登录页面',
'确认开始准备',
`正在完成 ${isMac ? 'macOS' : 'Windows'} 授权`,
'现在可以登录微信',
'验证数据库连接'
][guideStep - 1]}
</strong>
<p>
{statusKind === 'error'
? status
: status || '请保持微信客户端正在运行,系统将尝试安全获取数据库密钥。'}
: status ||
[
'确认下方检测结果;没有找到目录时可以手动选择。',
'请退出当前微信账号,让微信停留在登录页面,然后点击“我已准备好”。',
'开始后请按页面提示完成系统授权。',
'正在准备连接组件,请不要关闭微信或 WechatExplorer。',
'请回到微信完成登录,登录成功后再回来验证。',
'正在验证密钥和本地数据库,请稍候。'
][guideStep - 1]}
</p>
</div>
</div>
<dl className="database-login-diagnostics">
<div>
<dt></dt>
<dd>{isFetching ? '正在检测' : '等待检测'}</dd>
</div>
<div>
<dt>
<StoragePathHelp />
</dt>
<dd>
<span className="database-login-path-input-wrap">
<input
type="text"
value={dbRoot}
onChange={(event) => onDbRootChange(event.target.value)}
placeholder={defaultPath}
title={dbRoot || defaultPath}
aria-label="微信数据存储路径"
spellCheck={false}
onFocus={(event) => event.currentTarget.select()}
/>
<span className="database-login-path-value" role="status">
{dbRoot || defaultPath}
</span>
</span>
</dd>
</div>
<div>
<dt></dt>
<dd>{statusKind === 'error' ? '无法连接' : '准备连接'}</dd>
</div>
</dl>
{guideStep === 1 && (
<>
<dl className="database-login-diagnostics">
<div>
<dt></dt>
<dd>{environment?.osVersion || (isMac ? 'macOS' : 'Windows')}</dd>
</div>
<div>
<dt></dt>
<dd>{environment?.wechatVersion || '未检测到'}</dd>
</div>
<div>
<dt></dt>
<dd>{environment?.dataStructureVersion || '未检测到'}</dd>
</div>
<div>
<dt>
<StoragePathHelp />
</dt>
<dd>
<span className="database-login-path-input-wrap">
<input
type="text"
value={dbRoot}
onChange={(event) => onDbRootChange(event.target.value)}
placeholder={defaultPath}
title={dbRoot || defaultPath}
aria-label="微信数据存储路径"
spellCheck={false}
onFocus={(event) => event.currentTarget.select()}
/>
<span className="database-login-path-value" role="status">
{dbRoot || defaultPath}
</span>
</span>
<button
type="button"
className="database-login-path-select"
onClick={onSelectDbRoot}
disabled={isFetching || isConnecting}
>
</button>
</dd>
</div>
<div>
<dt></dt>
<dd>{environment?.wechatRunning ? '运行中' : '未检测到'}</dd>
</div>
</dl>
{accounts.length > 0 && (
<section className="database-account-list" aria-label="选择微信账号">
<h3></h3>
{accounts.map((account) => (
<button
type="button"
key={account.id}
className={`database-account-card ${selectedAccountId === account.id ? 'selected' : ''}`}
aria-pressed={selectedAccountId === account.id}
onClick={() => onSelectAccount(account)}
>
<span className="database-account-avatar">
{account.avatar ? (
<img src={account.avatar} alt="" />
) : (
(account.nickname || '?').charAt(0)
)}
</span>
<span className="database-account-identity">
<strong>{account.nickname || '昵称未识别'}</strong>
<small>{account.wxid || 'wxid 未识别'}</small>
<code title={account.accountRoot}>{account.accountRoot}</code>
</span>
<span className="database-account-status">
{account.hasSavedDbKey ? '已有可用密钥' : '尚无可用密钥'}
<small>
{account.loginStatus === 'current'
? '当前已连接账号'
: account.loginStatus === 'other'
? '非当前账号'
: '登录状态未确认'}
</small>
</span>
</button>
))}
<button
type="button"
className="database-login-secondary"
onClick={onSelectDbRoot}
disabled={isFetching || isConnecting}
>
</button>
</section>
)}
</>
)}
</div>
<button
type="button"
className="database-login-primary"
onClick={onAutoGetKey}
disabled={isFetching}
>
{isFetching ? '正在获取密钥…' : statusKind === 'error' ? '重新检测' : '开始获取'}
</button>
{guideStep === 1 && (
<>
<button
type="button"
className="database-login-primary"
onClick={onGuideNext}
disabled={!selectedAccountId}
>
</button>
<button
type="button"
className="database-login-secondary"
onClick={onRefreshEnvironment}
>
</button>
<button
type="button"
className="database-login-text-action"
onClick={onCopyDiagnostics}
>
</button>
</>
)}
{guideStep === 2 && (
<button type="button" className="database-login-primary" onClick={onGuideNext}>
</button>
)}
{guideStep === 3 && (
<button type="button" className="database-login-primary" onClick={onAutoGetKey}>
</button>
)}
{guideStep === 4 && (
<button type="button" className="database-login-primary" disabled>
</button>
)}
{guideStep === 5 && (
<button
type="button"
className="database-login-primary"
onClick={onValidateConnection}
disabled={!dbKey || isConnecting}
>
{isConnecting ? '正在验证…' : '微信已登录,验证连接'}
</button>
)}
{guideStep === 6 && (
<button type="button" className="database-login-primary" disabled>
</button>
)}
{guideStep > 1 && !isFetching && !isConnecting && (
<div className="database-login-guide-actions">
<button type="button" onClick={onGuideBack}>
</button>
<button type="button" onClick={onGuideCancel}>
</button>
</div>
)}
{(isFetching || isConnecting) && (
<button
type="button"
className="database-login-text-action"
onClick={onGuideCancel}
>
</button>
)}
<p className="database-login-platform-note">
{isMac ? (
<>
@@ -311,15 +492,21 @@ export function DatabaseConnectionPage({
<StoragePathHelp />
</label>
<input
id="database-login-root"
value={dbRoot}
onChange={(event) => onDbRootChange(event.target.value)}
placeholder={defaultPath}
title={dbRoot || defaultPath}
spellCheck={false}
onFocus={(event) => event.currentTarget.select()}
/>
<div className="database-login-root-control">
<input
id="database-login-root"
aria-label="微信数据目录"
value={dbRoot}
onChange={(event) => onDbRootChange(event.target.value)}
placeholder={defaultPath}
title={dbRoot || defaultPath}
spellCheck={false}
onFocus={(event) => event.currentTarget.select()}
/>
<button type="button" onClick={onSelectDbRoot} disabled={isConnecting}>
</button>
</div>
</div>
)}
{status && <div className={`database-login-message ${statusKind}`}>{status}</div>}
@@ -327,11 +514,25 @@ export function DatabaseConnectionPage({
type="button"
className="database-login-primary"
onClick={onManualConnect}
disabled={!keyIsValid}
disabled={!keyIsValid || isConnecting}
>
{isConnecting ? '正在连接…' : '连接数据库'}
</button>
<button type="button" className="database-login-secondary" onClick={onPasteKey}>
{isConnecting && (
<button
type="button"
className="database-login-text-action"
onClick={onGuideCancel}
>
</button>
)}
<button
type="button"
className="database-login-secondary"
onClick={onPasteKey}
disabled={isConnecting}
>
</button>
</div>
@@ -9,6 +9,7 @@ interface MessageListProps {
messages: Message[]
hiddenMessageCount: number
isLoadingMessages?: boolean
messageHistoryStatus?: 'idle' | 'end' | 'error'
isGroupChat: boolean
showAvatar: boolean
listRef: React.RefObject<HTMLDivElement | null>
@@ -24,6 +25,7 @@ export function MessageList({
messages,
hiddenMessageCount,
isLoadingMessages,
messageHistoryStatus,
isGroupChat,
showAvatar,
listRef,
@@ -119,6 +121,18 @@ export function MessageList({
return (
<div className="message-list wechat-message-list" ref={listRef} onScroll={handleScroll}>
{isLoadingMessages && <div className="message-loading-pill">...</div>}
{messageHistoryStatus === 'end' && (
<div className="wechat-system-message-row">
<div className="wechat-system-message"></div>
</div>
)}
{messageHistoryStatus === 'error' && (
<div className="wechat-system-message-row">
<div className="wechat-system-message">
</div>
</div>
)}
{hiddenMessageCount > 0 && (
<div className="wechat-system-message-row">
<div className="wechat-system-message">
@@ -41,7 +41,7 @@ export function ExportWorkspace({
const [includeAvatars, setIncludeAvatars] = useState(true)
const [preferOriginal, setPreferOriginal] = useState(true)
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
const [keepMissing, setKeepMissing] = useState(false)
const [keepMissing, setKeepMissing] = useState(true)
const [format, setFormat] = useState<ExportFormat>('csv')
const [zip, setZip] = useState(false)
const [fileName, setFileName] = useState('')
@@ -158,6 +158,8 @@ export function ExportWorkspace({
const handleStart = async (): Promise<void> => {
if (!activeContact || status === 'running') return
// Runs only from the export button event; a fresh id is required for each job.
// eslint-disable-next-line react-hooks/purity
const nextJobId = `export-${Date.now()}`
setJobId(nextJobId)
setProgress(null)
@@ -204,6 +206,9 @@ export function ExportWorkspace({
: undefined,
kinds: Array.from(selectedKinds) as ExportMessageKind[],
includeMedia,
preferOriginal,
fallbackThumbnail,
keepMissing,
includeAvatars,
avatarUrls: exportAvatarUrls,
nameMode,
@@ -303,20 +308,39 @@ export function ExportWorkspace({
<h3></h3>
<div className="export-format-grid">
{formatOrder.map((value) => (
<button key={value} type="button" className={format === value ? 'active' : ''} onClick={() => setFormat(value)}>
<button
key={value}
type="button"
className={format === value ? 'active' : ''}
onClick={() => setFormat(value)}
>
<strong>{formatLabels[value].label}</strong>
{formatLabels[value].hint && <small>{formatLabels[value].hint}</small>}
</button>
))}
</div>
<p className="export-helper-text">CSV HTML </p>
<p className="export-helper-text">
CSV HTML
</p>
{format === 'html' && (
<div className="export-html-options">
<label>
<input type="radio" name="html-package-top" checked={!zip} onChange={() => setZip(false)} /> HTML
<input
type="radio"
name="html-package-top"
checked={!zip}
onChange={() => setZip(false)}
/>{' '}
HTML
</label>
<label>
<input type="radio" name="html-package-top" checked={zip} onChange={() => setZip(true)} /> HTML ZIP
<input
type="radio"
name="html-package-top"
checked={zip}
onChange={() => setZip(true)}
/>{' '}
HTML ZIP
</label>
</div>
)}
@@ -383,19 +407,13 @@ export function ExportWorkspace({
<h3></h3>
<div className="export-kind-grid">
{messageKinds.map(([value, label]) => (
<label key={value} className={`export-check-row ${value === 'video' ? 'unsupported' : ''}`}>
<label key={value} className="export-check-row">
<input
type="checkbox"
checked={value !== 'video' && selectedKinds.has(value)}
disabled={value === 'video'}
checked={selectedKinds.has(value)}
onChange={() => toggleKind(value)}
/>
<span>{label}</span>
{value === 'video' && (
<span className="export-unsupported-hint" title="当前版本暂不支持视频导出" aria-label="当前版本暂不支持视频导出">
!
</span>
)}
</label>
))}
</div>
@@ -424,12 +442,14 @@ export function ExportWorkspace({
<span></span>
<input
type="checkbox"
checked={includeMedia}
disabled={format !== 'html'}
checked={includeMedia}
disabled={format !== 'html'}
onChange={(event) => setIncludeMedia(event.target.checked)}
/>
</label>
<div className={`export-media-options ${includeMedia && format === 'html' ? '' : 'disabled'}`}>
<div
className={`export-media-options ${includeMedia && format === 'html' ? '' : 'disabled'}`}
>
<label className="export-check-row">
<input
type="checkbox"
@@ -458,7 +478,9 @@ export function ExportWorkspace({
<span></span>
</label>
</div>
<p className="export-helper-text"> HTML CSVJSON Markdown </p>
<p className="export-helper-text">
HTML CSVJSON Markdown
</p>
<div className="export-resource-statuses">
<span></span>
<span></span>
@@ -30,7 +30,8 @@ export function SettingsWorkspace({
onAIRuntimeChange,
onNotice,
onOpenSettings,
onAppearanceChange
onAppearanceChange,
onSwitchAccount
}: {
selectedCategory: SettingsCategoryId
onCategoryChange: (id: SettingsCategoryId) => void
@@ -47,7 +48,13 @@ export function SettingsWorkspace({
onAIRuntimeChange: (config: AIRuntimeModelConfig) => void
onNotice: (message: string) => void
onOpenSettings: () => void
onAppearanceChange: (settings: { theme: 'system' | 'light' | 'dark'; compactMode: boolean }) => void
onAppearanceChange: (settings: {
theme: 'system' | 'light' | 'dark'
compactMode: boolean
}) => void
onSwitchAccount: (
account: import('../../../../shared/database-key').WechatAccountCandidate
) => Promise<void>
}): React.ReactElement {
const renderSelectedPage = (): React.ReactElement => {
switch (selectedCategory) {
@@ -59,6 +66,7 @@ export function SettingsWorkspace({
dbConnecting={dbConnecting}
selfInfo={selfInfo}
onNotice={onNotice}
onSwitchAccount={onSwitchAccount}
/>
)
case 'database-key':
@@ -87,9 +95,7 @@ export function SettingsWorkspace({
case 'cache-cleanup':
return <CacheCleanupPage onNotice={onNotice} />
case 'appearance':
return (
<AppearancePage onNotice={onNotice} onAppearanceChange={onAppearanceChange} />
)
return <AppearancePage onNotice={onNotice} onAppearanceChange={onAppearanceChange} />
case 'about':
return <AboutPage onNotice={onNotice} />
default:
@@ -25,7 +25,8 @@ export function AccountOverview({
isChecking,
onCheck,
onOpenDirectory,
onCopyDirectory
onCopyDirectory,
onSwitchAccount
}: {
selfInfo: SettingsSelfInfo | null
connectionStatus: ConnectionOverviewStatus
@@ -34,6 +35,7 @@ export function AccountOverview({
onCheck: () => void
onOpenDirectory: () => void
onCopyDirectory: () => void
onSwitchAccount: () => void
}): React.ReactElement {
const accountRoot = selfInfo?.accountRoot || ''
return (
@@ -83,6 +85,9 @@ export function AccountOverview({
>
</button>
<button type="button" className="api-secondary-button" onClick={onSwitchAccount}>
</button>
</div>
<div className="settings-account-root">
@@ -36,14 +36,14 @@ export function useDatabaseKeyController({
}, [])
const refreshStorage = useCallback(async (): Promise<void> => {
const result = await window.api.getSavedDbKey()
const result = await window.api.getSavedDbKey(selfInfo?.accountRoot || '')
dispatch({
type: 'STORAGE_LOADED',
saved: result.saved,
encryptionAvailable: result.encryptionAvailable,
error: result.success ? undefined : result.error
})
}, [])
}, [selfInfo?.accountRoot])
useEffect(() => {
void Promise.all([refreshStorage(), refreshEnvironment()])
@@ -109,7 +109,8 @@ export function useDatabaseKeyController({
const saveKey = useCallback(async (): Promise<void> => {
if (state.status !== 'valid') return
dispatch({ type: 'SAVE_START' })
const saved = await window.api.saveDbKey(dbKey)
const accountRoot = selfInfo?.accountRoot || ''
const saved = await window.api.saveDbKey(accountRoot, dbKey)
if (!saved.success || !saved.key) {
dispatch({
type: 'SAVE_ERROR',
@@ -117,12 +118,12 @@ export function useDatabaseKeyController({
})
return
}
const stored = await window.api.getSavedDbKey()
const stored = await window.api.getSavedDbKey(accountRoot)
if (!stored.success || !stored.saved || !stored.key) {
dispatch({ type: 'SAVE_ERROR', error: '无法确认密钥保存状态' })
return
}
const initialized = await window.api.initDb(stored.key)
const initialized = await window.api.initDb(stored.key, accountRoot)
const connected = typeof initialized === 'boolean' ? initialized : initialized.success
onDbKeyChange(stored.key)
onDatabaseConnectionChange(connected)
@@ -148,13 +149,14 @@ export function useDatabaseKeyController({
onNotice,
onSelfInfoChange,
refreshEnvironment,
selfInfo?.accountRoot,
state.status
])
const autoDetectKey = useCallback(async (): Promise<void> => {
dispatch({ type: 'AUTO_START' })
await refreshEnvironment()
const result = await window.api.autoGetDbKey({ save: false })
const result = await window.api.autoGetDbKey(selfInfo?.accountRoot || '', { save: false })
if (!result.success || !result.key) {
dispatch({ type: 'AUTO_ERROR', error: result.error || '暂未找到有效密钥' })
return
@@ -162,11 +164,11 @@ export function useDatabaseKeyController({
onDbKeyChange(result.key)
dispatch({ type: 'AUTO_SUCCESS' })
await runValidation(result.key)
}, [onDbKeyChange, refreshEnvironment, runValidation])
}, [onDbKeyChange, refreshEnvironment, runValidation, selfInfo?.accountRoot])
const clearSavedKey = useCallback(async (): Promise<void> => {
dispatch({ type: 'CLEAR_START' })
const result = await window.api.clearSavedDbKey()
const result = await window.api.clearSavedDbKey(selfInfo?.accountRoot || '')
if (!result.success) {
dispatch({ type: 'CLEAR_ERROR', error: '清除密钥失败' })
return
@@ -187,7 +189,8 @@ export function useDatabaseKeyController({
onFilteredContactsChange,
onNotice,
onSelfInfoChange,
refreshEnvironment
refreshEnvironment,
selfInfo?.accountRoot
])
const returnToLogin = useCallback(async (): Promise<void> => {
@@ -5,6 +5,7 @@ import { LocalPrivacyNotice } from '../account-database/LocalPrivacyNotice'
import { useAccountDatabaseController } from '../account-database/useAccountDatabaseController'
import type { ConnectionOverviewStatus } from '../account-database/types'
import type { SettingsSelfInfo } from '../model/types'
import type { WechatAccountCandidate } from '../../../../../shared/database-key'
const STATUS_LABELS: Record<ConnectionOverviewStatus, string> = {
checking: '正在检测',
@@ -19,13 +20,15 @@ export function AccountDatabasePage({
dbReady,
dbConnecting = false,
selfInfo,
onNotice
onNotice,
onSwitchAccount
}: {
dbKey: string
dbReady: boolean
dbConnecting?: boolean
selfInfo: SettingsSelfInfo | null
onNotice: (message: string) => void
onSwitchAccount: (account: WechatAccountCandidate) => Promise<void>
}): React.ReactElement {
const controller = useAccountDatabaseController({
dbKey,
@@ -35,6 +38,8 @@ export function AccountDatabasePage({
onNotice
})
const [autoLogin, setAutoLogin] = useState(false)
const [switching, setSwitching] = useState(false)
const [accounts, setAccounts] = useState<WechatAccountCandidate[]>([])
useEffect(() => {
let active = true
@@ -56,6 +61,18 @@ export function AccountDatabasePage({
onNotice(checked ? '已开启启动时自动连接' : '已关闭启动时自动连接')
}
const openAccountSwitcher = async (): Promise<void> => {
if (!selfInfo?.accountRoot) return
const parentRoot = selfInfo.accountRoot.replace(/[\\/][^\\/]+[\\/]?$/, '')
const result = await window.api.discoverAccounts(parentRoot)
if (!result.success) {
onNotice(result.error || '无法读取账号列表')
return
}
setAccounts(result.accounts)
setSwitching(true)
}
return (
<div className="settings-page">
<header className="settings-page-header">
@@ -79,7 +96,45 @@ export function AccountDatabasePage({
onCheck={() => void controller.testConnection()}
onOpenDirectory={() => void controller.openAccountDirectory()}
onCopyDirectory={() => void controller.copyAccountDirectory()}
onSwitchAccount={() => void openAccountSwitcher()}
/>
{switching && (
<section className="settings-card database-account-list" aria-label="切换微信账号">
<h2></h2>
{accounts.map((account) => (
<button
type="button"
key={account.id}
className="database-account-card"
disabled={account.accountRoot === selfInfo?.accountRoot}
onClick={() => void onSwitchAccount(account).then(() => setSwitching(false))}
>
<span className="database-account-avatar">
{account.avatar ? (
<img src={account.avatar} alt="" />
) : (
(account.nickname || '?').charAt(0)
)}
</span>
<span className="database-account-identity">
<strong>{account.nickname || '昵称未识别'}</strong>
<small>{account.wxid || 'wxid 未识别'}</small>
<code>{account.accountRoot}</code>
</span>
<span className="database-account-status">
{account.hasSavedDbKey ? '已有可用密钥' : '需要获取密钥'}
</span>
</button>
))}
<button
type="button"
className="api-secondary-button"
onClick={() => setSwitching(false)}
>
</button>
</section>
)}
<h2 className="settings-section-heading"></h2>
<ConnectionHealthSection
diagnostics={controller.diagnostics}
+155 -3
View File
@@ -1185,7 +1185,7 @@
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: 48px clamp(40px, 7vw, 96px);
padding: 28px clamp(40px, 7vw, 96px);
background: #fff;
}
@@ -1195,7 +1195,7 @@
}
.database-login-start {
margin-bottom: 22px;
margin-bottom: 14px;
h2 {
margin: 0;
@@ -1285,7 +1285,7 @@
grid-template-columns: repeat(2, 1fr);
gap: 4px;
padding: 4px;
margin-bottom: 28px;
margin-bottom: 18px;
border: 1px solid var(--login-border);
border-radius: 8px;
background: #e9eeeb;
@@ -1410,6 +1410,58 @@
text-align: right;
}
.database-login-guide-progress {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 5px;
margin: 0 0 12px;
}
.database-login-guide-progress span {
height: 4px;
border-radius: 2px;
background: #dce4df;
}
.database-login-guide-progress span.active {
background: var(--login-primary);
}
.database-login-guide-actions {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
}
.database-login-guide-actions button,
.database-login-text-action {
padding: 5px 0;
border: 0;
color: var(--login-primary-dark);
background: transparent;
cursor: pointer;
font-size: 11px;
}
.database-login-text-action {
display: block;
width: 100%;
margin-top: 10px;
text-align: center;
}
.database-login-path-select {
margin-top: 5px;
padding: 4px 8px;
border: 1px solid var(--login-border);
border-radius: 5px;
color: var(--login-primary-dark);
background: #fff;
cursor: pointer;
font-size: 11px;
}
.database-login-path-input-wrap {
position: relative;
display: block;
@@ -1596,6 +1648,7 @@
}
.database-login-field > input,
.database-login-root-control,
.database-login-key-input {
width: 100%;
min-height: 42px;
@@ -1604,6 +1657,37 @@
background: #fff;
}
.database-login-root-control {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
overflow: hidden;
}
.database-login-root-control input {
min-width: 0;
padding: 0 12px;
border: 0;
outline: 0;
color: var(--login-text);
background: transparent;
font-size: 13px;
}
.database-login-root-control button {
padding: 0 12px;
border: 0;
border-left: 1px solid var(--login-border);
color: var(--login-primary-dark);
background: var(--login-surface-low);
cursor: pointer;
}
.database-login-root-control button:disabled,
.database-login-path-select:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.database-login-field > input {
padding: 0 12px;
color: var(--login-text);
@@ -1617,6 +1701,7 @@
}
.database-login-key-input:focus-within,
.database-login-root-control:focus-within,
.database-login-field > input:focus {
border-color: var(--login-primary);
box-shadow: 0 0 0 3px rgba(36, 122, 99, 0.1);
@@ -1969,3 +2054,70 @@
pointer-events: none;
}
}
.database-account-list {
display: grid;
gap: 10px;
margin: 14px 0;
h3 {
margin: 0;
font-size: 14px;
}
}
.database-account-card {
width: 100%;
display: grid;
grid-template-columns: 44px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 12px;
border: 1px solid var(--border-color, #d8e2dc);
border-radius: 8px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
&.selected {
border-color: #176b57;
box-shadow: 0 0 0 2px #176b5720;
}
}
.database-account-avatar {
width: 44px;
height: 44px;
display: grid;
place-items: center;
overflow: hidden;
border-radius: 6px;
background: #dcebe4;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.database-account-identity,
.database-account-status {
display: grid;
gap: 3px;
min-width: 0;
small,
code {
color: var(--text-secondary, #68766f);
}
code {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.database-account-status {
text-align: right;
}