mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-22 05:56:58 +08:00
feat: 完善多账号连接诊断与聊天媒体导出
- 新增微信账号发现、环境诊断和分步数据库连接引导 - 支持按账号安全保存数据库密钥及快速切换账号 - 完善 WCDB 历史消息分片读取和分页状态提示 - 支持导出图片、视频和语音,提供原图优先及缩略图回退 - 更新安装指引、兼容版本说明和相关自动化测试
This commit is contained in:
+246
-18
@@ -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 导出中生效,CSV、JSON 和 Markdown 只保留文本内容。</p>
|
||||
<p className="export-helper-text">
|
||||
资源文件仅在 HTML 导出中生效,CSV、JSON 和 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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user