fix: 优化数据库启动与设置页响应性能

(cherry picked from commit f6614548b0e742bc703b2a5aaa5060720985ed27)
This commit is contained in:
电摇小子
2026-08-03 10:04:19 +08:00
committed by Wxw-Gu
parent 90bf1aed90
commit ee7dc11e92
14 changed files with 202 additions and 94 deletions
+5 -1
View File
@@ -486,6 +486,7 @@ app.whenReady().then(async () => {
if (dbInitInFlight) return dbInitInFlight
dbInitInFlight = (async () => {
const startedAt = Date.now()
try {
if (wcdbBootstrapPromise) await wcdbBootstrapPromise
const trimmedKey = String(key || '').trim()
@@ -511,7 +512,7 @@ app.whenReady().then(async () => {
}
chat.setChatDb(nextWechatDb)
const wcdb4Client = nextWechatDb.getWcdb4Client()
const sessions = await wcdb4Client.getSessionsAsync()
const sessions = await wcdb4Client.getSessionsAsync({ hydrateDisplayNames: false })
configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled)
voiceService = new VoiceService(wcdb4Client)
stickerService = new StickerService(wcdb4Client)
@@ -530,6 +531,9 @@ app.whenReady().then(async () => {
.catch((error) => console.warn('[WCDB4] message cursor warmup failed:', error))
}
imageDecryptService = null
console.log(
`[WCDB4] db:init ready sessions=${sessions.length} monitoring=${monitoring} cost=${Date.now() - startedAt}ms`
)
return { success: true, monitoring }
} catch (error) {
console.error('Failed to init DB:', error)
+1 -1
View File
@@ -174,7 +174,7 @@ export function listContacts(filter?: string): FormattedContact[] {
export async function listContactsAsync(filter?: string): Promise<FormattedContact[]> {
if (!dbRef) return []
await dbRef.getWcdb4Client().getSessionsAsync()
await dbRef.getWcdb4Client().getSessionsAsync({ hydrateDisplayNames: false })
return listContacts(filter)
}
+53 -16
View File
@@ -32,6 +32,10 @@ export interface Wcdb4MessageQueryOptions {
limit?: number
}
export interface Wcdb4SessionQueryOptions {
hydrateDisplayNames?: boolean
}
type Wcdb4MessageStore = {
tableName: string
dbPath: string
@@ -242,6 +246,8 @@ export class Wcdb4Client {
private cachedSessions: Wcdb4Session[] | null = null
private cachedChatTables: { name: string; db_number: string }[] | null = null
private sessionsInFlight: Promise<Wcdb4Session[]> | null = null
private sessionDisplayNamesInFlight: Promise<void> | null = null
private sessionDisplayNamesHydrated = false
private sessionCacheGeneration = 0
private wcdbShutdown: (() => number) | null = null
@@ -558,6 +564,7 @@ export class Wcdb4Client {
this.handle = null
this.cachedSessions = null
this.sessionDisplayNamesHydrated = false
this.displayNameCache.clear()
this.avatarCache.clear()
this.groupNicknameCache.clear()
@@ -722,13 +729,22 @@ export class Wcdb4Client {
...session,
nickname: this.displayNameCache.get(session.username) || session.nickname || session.username
}))
this.sessionDisplayNamesHydrated = true
return this.cachedSessions
}
async getSessionsAsync(): Promise<Wcdb4Session[]> {
if (this.cachedSessions) return this.cachedSessions
if (this.sessionsInFlight) return this.sessionsInFlight
async getSessionsAsync(options: Wcdb4SessionQueryOptions = {}): Promise<Wcdb4Session[]> {
const hydrateDisplayNames = options.hydrateDisplayNames !== false
if (this.cachedSessions) {
if (hydrateDisplayNames) await this.ensureSessionDisplayNamesAsync()
return this.cachedSessions
}
if (this.sessionsInFlight) {
await this.sessionsInFlight
if (hydrateDisplayNames) await this.ensureSessionDisplayNamesAsync()
return this.cachedSessions || []
}
if (!this.wcdbGetSessions) return []
const generation = this.sessionCacheGeneration
@@ -739,25 +755,17 @@ export class Wcdb4Client {
const sessions = (Array.isArray(rows) ? rows : [])
.map((row) => this.normalizeSession(row))
.filter((session) => session.username)
await this.hydrateDisplayNamesAsync(
sessions
.filter((session) => this.shouldHydrateSessionDisplayName(session))
.map((session) => session.username)
)
const hydrated = sessions.map((session) => ({
...session,
nickname:
this.displayNameCache.get(session.username) || session.nickname || session.username
}))
if (generation === this.sessionCacheGeneration) this.cachedSessions = hydrated
return hydrated
if (generation === this.sessionCacheGeneration) this.cachedSessions = sessions
return sessions
})()
this.sessionsInFlight = request
try {
return await request
await request
} finally {
if (this.sessionsInFlight === request) this.sessionsInFlight = null
}
if (hydrateDisplayNames) await this.ensureSessionDisplayNamesAsync()
return this.cachedSessions || []
}
invalidateSessionCache(): void {
@@ -765,6 +773,7 @@ export class Wcdb4Client {
this.cachedSessions = null
this.cachedChatTables = null
this.sessionsInFlight = null
this.sessionDisplayNamesHydrated = false
}
getChatTables(): { name: string; db_number: string }[] {
@@ -2214,6 +2223,34 @@ export class Wcdb4Client {
}
}
private async ensureSessionDisplayNamesAsync(): Promise<void> {
if (this.sessionDisplayNamesHydrated || !this.cachedSessions) return
if (this.sessionDisplayNamesInFlight) return this.sessionDisplayNamesInFlight
const generation = this.sessionCacheGeneration
const sessions = this.cachedSessions
const request = (async (): Promise<void> => {
await this.hydrateDisplayNamesAsync(
sessions
.filter((session) => this.shouldHydrateSessionDisplayName(session))
.map((session) => session.username)
)
if (generation !== this.sessionCacheGeneration || this.cachedSessions !== sessions) return
this.cachedSessions = sessions.map((session) => ({
...session,
nickname:
this.displayNameCache.get(session.username) || session.nickname || session.username
}))
this.sessionDisplayNamesHydrated = true
})()
this.sessionDisplayNamesInFlight = request
try {
await request
} finally {
if (this.sessionDisplayNamesInFlight === request) this.sessionDisplayNamesInFlight = null
}
}
private hydrateAvatarUrls(usernames: string[]): void {
const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username))
if (missing.length === 0) return
+16
View File
@@ -210,6 +210,7 @@ const mergeMessagePages = (older: Message[], current: Message[]): Message[] => {
function App(): React.ReactElement {
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isDatabaseConnected, setIsDatabaseConnected] = useState(false)
const [isDatabaseConnecting, setIsDatabaseConnecting] = useState(false)
const [dbKey, setDbKey] = useState(getDevelopmentDatabaseKey)
const [contacts, setContacts] = useState<Contact[]>([])
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
@@ -582,6 +583,7 @@ function App(): React.ReactElement {
if (!autoLoginEnabled) return
try {
const startupCacheReady = await loadStartupCache()
setIsDatabaseConnecting(true)
const initPromise = window.api.initDb(key)
if (startupCacheReady) {
setIsAuthenticated(true)
@@ -606,6 +608,9 @@ function App(): React.ReactElement {
console.warn('[Startup] background database init failed:', error)
setDbKeyStatusKind('error')
})
.finally(() => {
if (active) setIsDatabaseConnecting(false)
})
return
}
const result = await initPromise
@@ -638,6 +643,8 @@ function App(): React.ReactElement {
setDbKeyStatus(`自动连接失败: ${message}`)
setDbKeyStatusKind('error')
setBootState('login')
} finally {
if (active) setIsDatabaseConnecting(false)
}
}
void attemptAutoConnect()
@@ -663,6 +670,7 @@ function App(): React.ReactElement {
const keyToUse = keyInput || dbKey
if (!keyToUse) return
setBootState('connecting')
setIsDatabaseConnecting(true)
// 持久化用户手动指定的微信聊天文件路径,供 db:init 读取 settings.dbRoot
const trimmedRoot = dbRootInput.trim()
if (trimmedRoot) {
@@ -746,6 +754,8 @@ function App(): React.ReactElement {
setBootState('login')
setStartupProgress(null)
alert('Error connecting to database')
} finally {
setIsDatabaseConnecting(false)
}
}
@@ -918,6 +928,7 @@ function App(): React.ReactElement {
const handleReturnToLogin = (): void => {
setIsAuthenticated(false)
setIsDatabaseConnected(false)
setIsDatabaseConnecting(false)
setBootState('login')
setDatabaseConnectionMode(dbKey ? 'manual' : 'automatic')
setActivePage('archive')
@@ -1425,6 +1436,7 @@ function App(): React.ReactElement {
width={sidebarWidth}
selfInfo={selfInfo}
dbReady={isDatabaseConnected}
dbConnecting={isDatabaseConnecting}
onOpenSettings={openSettings}
/>
<div className="resizer" onMouseDown={startResizing} />
@@ -1454,6 +1466,7 @@ function App(): React.ReactElement {
selectedReportId={selectedReportId}
selfInfo={selfInfo}
dbReady={isDatabaseConnected}
dbConnecting={isDatabaseConnecting}
onSelectReport={openReport}
onCreateReport={openReportConfigure}
onDeleteReport={handleDeleteReport}
@@ -1476,6 +1489,7 @@ function App(): React.ReactElement {
selectedContact={reportSourceContact}
selfInfo={selfInfo}
dbReady={isDatabaseConnected}
dbConnecting={isDatabaseConnecting}
onSelectContact={handleSelectReportSource}
onOpenSettings={openSettings}
/>
@@ -1546,6 +1560,7 @@ function App(): React.ReactElement {
onCategoryChange={setSettingsCategory}
selfInfo={selfInfo}
dbReady={isDatabaseConnected}
dbConnecting={isDatabaseConnecting}
dbKey={dbKey}
onDbKeyChange={setDbKey}
onDatabaseConnectionChange={setIsDatabaseConnected}
@@ -1692,6 +1707,7 @@ function App(): React.ReactElement {
activePage={activePage}
selfInfo={selfInfo}
dbReady={isDatabaseConnected}
dbConnecting={isDatabaseConnecting}
onPageChange={handlePageChange}
onOpenSettings={openSettings}
onOpenGuide={openFirstUseGuide}
@@ -10,6 +10,7 @@ interface SelfInfo {
interface AccountSummaryProps {
selfInfo: SelfInfo | null
dbReady: boolean
dbConnecting?: boolean
compact?: boolean
onClick?: () => void
}
@@ -17,13 +18,20 @@ interface AccountSummaryProps {
export function AccountSummary({
selfInfo,
dbReady,
dbConnecting = false,
compact = false,
onClick
}: AccountSummaryProps): React.ReactElement {
const showAccount = Boolean(selfInfo && (dbReady || dbConnecting))
const displayName =
dbReady && selfInfo ? selfInfo.nickname || selfInfo.wxid || '当前账号' : '未连接'
const subtitle = dbReady && selfInfo ? selfInfo.wxid : '打开设置'
const statusText = dbReady ? '数据库已连接' : '数据库未连接'
showAccount && selfInfo ? selfInfo.nickname || selfInfo.wxid || '当前账号' : '未连接'
const subtitle = showAccount && selfInfo ? selfInfo.wxid : '打开设置'
const statusText = dbReady
? '数据库已连接'
: dbConnecting
? '正在连接数据库'
: '数据库未连接'
const statusClass = dbReady ? 'ready' : dbConnecting ? 'connecting' : ''
const initial = (displayName || '?').charAt(0)
const title = `${displayName}\n${subtitle}`
const avatar = (
@@ -33,7 +41,7 @@ export function AccountSummary({
) : (
initial
)}
<span className={`account-summary-status ${dbReady ? 'ready' : ''}`} aria-hidden />
<span className={`account-summary-status ${statusClass}`} aria-hidden />
</span>
)
@@ -52,7 +60,7 @@ export function AccountSummary({
<span className="account-summary-name">{displayName}</span>
<span className="account-summary-meta">{subtitle}</span>
<span className="account-summary-state">
<span className={`account-summary-state-dot ${dbReady ? 'ready' : ''}`} aria-hidden />
<span className={`account-summary-state-dot ${statusClass}`} aria-hidden />
{statusText}
</span>
</span>
@@ -21,6 +21,7 @@ export interface ConversationSidebarProps {
width: number
selfInfo: SelfInfo | null
dbReady: boolean
dbConnecting?: boolean
onOpenSettings: () => void
}
@@ -37,6 +38,7 @@ export function ConversationSidebar({
width,
selfInfo,
dbReady,
dbConnecting = false,
onOpenSettings
}: ConversationSidebarProps): React.ReactElement {
const [searchTerm, setSearchTerm] = useState('')
@@ -124,7 +126,12 @@ export function ConversationSidebar({
</div>
</div>
<div className="conversation-sidebar-account">
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} />
<AccountSummary
selfInfo={selfInfo}
dbReady={dbReady}
dbConnecting={dbConnecting}
onClick={onOpenSettings}
/>
</div>
</aside>
)
@@ -15,6 +15,7 @@ interface AppShellProps {
activePage: AppPage
selfInfo: SelfInfo | null
dbReady: boolean
dbConnecting?: boolean
onPageChange: (page: AppPage) => void
onOpenSettings: () => void
onOpenGuide: () => void
@@ -35,6 +36,7 @@ export function AppShell({
activePage,
selfInfo,
dbReady,
dbConnecting = false,
onPageChange,
onOpenSettings,
onOpenGuide,
@@ -63,7 +65,13 @@ export function AppShell({
<span className="app-guide-launcher-label"></span>
</button>
<div className="app-rail-account">
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} compact onClick={onOpenSettings} />
<AccountSummary
selfInfo={selfInfo}
dbReady={dbReady}
dbConnecting={dbConnecting}
compact
onClick={onOpenSettings}
/>
</div>
</aside>
<main className="app-shell-main" aria-label={activeItem?.label || '工作区'}>
@@ -14,6 +14,7 @@ interface ReportHistorySidebarProps {
selectedReportId: string | null
selfInfo: SelfInfo | null
dbReady: boolean
dbConnecting?: boolean
onSelectReport: (reportId: string) => void
onCreateReport: () => void
onDeleteReport: (reportId: string) => Promise<{ success: boolean; error?: string }>
@@ -80,6 +81,7 @@ export function ReportHistorySidebar({
selectedReportId,
selfInfo,
dbReady,
dbConnecting = false,
onSelectReport,
onCreateReport,
onDeleteReport,
@@ -202,7 +204,12 @@ export function ReportHistorySidebar({
)}
</div>
<div className="report-history-account">
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} />
<AccountSummary
selfInfo={selfInfo}
dbReady={dbReady}
dbConnecting={dbConnecting}
onClick={onOpenSettings}
/>
</div>
{pendingDelete && (
<div className="report-delete-confirm" role="dialog" aria-modal="true">
@@ -14,6 +14,7 @@ interface ReportSourceSidebarProps {
selectedContact: Contact | null
selfInfo: SelfInfo | null
dbReady: boolean
dbConnecting?: boolean
onSelectContact: (contact: Contact) => void
onOpenSettings: () => void
}
@@ -23,6 +24,7 @@ export function ReportSourceSidebar({
selectedContact,
selfInfo,
dbReady,
dbConnecting = false,
onSelectContact,
onOpenSettings
}: ReportSourceSidebarProps): React.ReactElement {
@@ -95,6 +97,7 @@ export function ReportSourceSidebar({
<AccountSummary
selfInfo={selfInfo}
dbReady={dbReady}
dbConnecting={dbConnecting}
onClick={onOpenSettings}
/>
</div>
@@ -19,6 +19,7 @@ export function SettingsWorkspace({
onCategoryChange,
selfInfo,
dbReady,
dbConnecting = false,
dbKey,
onDbKeyChange,
onDatabaseConnectionChange,
@@ -35,6 +36,7 @@ export function SettingsWorkspace({
onCategoryChange: (id: SettingsCategoryId) => void
selfInfo: SettingsSelfInfo | null
dbReady: boolean
dbConnecting?: boolean
dbKey: string
onDbKeyChange: (key: string) => void
onDatabaseConnectionChange: (connected: boolean) => void
@@ -47,26 +49,20 @@ export function SettingsWorkspace({
onOpenSettings: () => void
onAppearanceChange: (settings: { theme: 'system' | 'light' | 'dark'; compactMode: boolean }) => void
}): React.ReactElement {
const renderSelectedPage = (): React.ReactElement => {
switch (selectedCategory) {
case 'account-database':
return (
<div className="settings-workspace">
<SettingsSidebar
selectedId={selectedCategory}
onSelect={onCategoryChange}
selfInfo={selfInfo}
dbReady={dbReady}
onOpenSettings={onOpenSettings}
/>
<div
className={`settings-page-panel ${selectedCategory === 'account-database' ? 'active' : ''}`}
>
<AccountDatabasePage
dbKey={dbKey}
dbReady={dbReady}
dbConnecting={dbConnecting}
selfInfo={selfInfo}
onNotice={onNotice}
/>
</div>
<div className={`settings-page-panel ${selectedCategory === 'database-key' ? 'active' : ''}`}>
)
case 'database-key':
return (
<DatabaseKeyPage
dbKey={dbKey}
dbReady={dbReady}
@@ -79,45 +75,39 @@ export function SettingsWorkspace({
onReturnToLogin={onReturnToLogin}
onNotice={onNotice}
/>
</div>
<div className={`settings-page-panel ${selectedCategory === 'image-key' ? 'active' : ''}`}>
<ImageDecryptionPage selfInfo={selfInfo} onNotice={onNotice} />
</div>
<div className={`settings-page-panel ${selectedCategory === 'ai-model' ? 'active' : ''}`}>
<AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
</div>
<div
className={`settings-page-panel ${selectedCategory === 'recall-protection' ? 'active' : ''}`}
>
<RecallProtectionPage onNotice={onNotice} />
</div>
<div className={`settings-page-panel ${selectedCategory === 'advanced' ? 'active' : ''}`}>
<AdvancedPage onNotice={onNotice} />
</div>
<div className={`settings-page-panel ${selectedCategory === 'cache-cleanup' ? 'active' : ''}`}>
<CacheCleanupPage onNotice={onNotice} />
</div>
<div className={`settings-page-panel ${selectedCategory === 'appearance' ? 'active' : ''}`}>
)
case 'image-key':
return <ImageDecryptionPage selfInfo={selfInfo} onNotice={onNotice} />
case 'ai-model':
return <AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
case 'recall-protection':
return <RecallProtectionPage onNotice={onNotice} />
case 'advanced':
return <AdvancedPage onNotice={onNotice} />
case 'cache-cleanup':
return <CacheCleanupPage onNotice={onNotice} />
case 'appearance':
return (
<AppearancePage onNotice={onNotice} onAppearanceChange={onAppearanceChange} />
</div>
<div className={`settings-page-panel ${selectedCategory === 'about' ? 'active' : ''}`}>
<AboutPage onNotice={onNotice} />
</div>
{![
'account-database',
'database-key',
'image-key',
'ai-model',
'recall-protection',
'advanced',
'cache-cleanup',
'appearance',
'about'
].includes(selectedCategory) && (
<div className="settings-page-panel active">
<SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
</div>
)}
)
case 'about':
return <AboutPage onNotice={onNotice} />
default:
return <SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
}
}
return (
<div className="settings-workspace">
<SettingsSidebar
selectedId={selectedCategory}
onSelect={onCategoryChange}
selfInfo={selfInfo}
dbReady={dbReady}
dbConnecting={dbConnecting}
onOpenSettings={onOpenSettings}
/>
<div className="settings-page-panel active">{renderSelectedPage()}</div>
</div>
)
}
@@ -13,11 +13,13 @@ import type { ConnectionCheckState } from './types'
export function useAccountDatabaseController({
dbKey,
dbReady,
dbConnecting = false,
selfInfo,
onNotice
}: {
dbKey: string
dbReady: boolean
dbConnecting?: boolean
selfInfo: SettingsSelfInfo | null
onNotice: (message: string) => void
}) {
@@ -28,7 +30,7 @@ export function useAccountDatabaseController({
useEffect(() => {
let active = true
void window.api
.getImageDecryptionStatus()
.getImageKeyConfig()
.then((result) => {
if (active) setHasImageKey(result.configured)
})
@@ -58,8 +60,11 @@ export function useAccountDatabaseController({
[checkState, dbKey, dbReady, hasImageKey, selfInfo]
)
const connectionStatus = useMemo(
() => getConnectionOverviewStatus({ dbReady, checkState, diagnostics }),
[checkState, dbReady, diagnostics]
() =>
dbConnecting
? ('checking' as const)
: getConnectionOverviewStatus({ dbReady, checkState, diagnostics }),
[checkState, dbConnecting, dbReady, diagnostics]
)
const lastCheckedLabel = formatConnectionCheckedAt(checkState, clock)
@@ -121,7 +126,7 @@ export function useAccountDatabaseController({
diagnostics,
connectionStatus,
checkState,
isChecking: checkState.status === 'checking',
isChecking: dbConnecting || checkState.status === 'checking',
lastCheckedLabel,
testConnection,
openAccountDirectory,
@@ -8,12 +8,14 @@ export function SettingsSidebar({
onSelect,
selfInfo,
dbReady,
dbConnecting = false,
onOpenSettings
}: {
selectedId: SettingsCategoryId
onSelect: (id: SettingsCategoryId) => void
selfInfo: SettingsSelfInfo | null
dbReady: boolean
dbConnecting?: boolean
onOpenSettings: () => void
}): React.ReactElement {
const [keyword, setKeyword] = useState('')
@@ -57,7 +59,12 @@ export function SettingsSidebar({
))}
</div>
<div className="settings-sidebar-account">
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} />
<AccountSummary
selfInfo={selfInfo}
dbReady={dbReady}
dbConnecting={dbConnecting}
onClick={onOpenSettings}
/>
</div>
</aside>
)
@@ -17,15 +17,23 @@ const STATUS_LABELS: Record<ConnectionOverviewStatus, string> = {
export function AccountDatabasePage({
dbKey,
dbReady,
dbConnecting = false,
selfInfo,
onNotice
}: {
dbKey: string
dbReady: boolean
dbConnecting?: boolean
selfInfo: SettingsSelfInfo | null
onNotice: (message: string) => void
}): React.ReactElement {
const controller = useAccountDatabaseController({ dbKey, dbReady, selfInfo, onNotice })
const controller = useAccountDatabaseController({
dbKey,
dbReady,
dbConnecting,
selfInfo,
onNotice
})
const [autoLogin, setAutoLogin] = useState(false)
useEffect(() => {
+8
View File
@@ -253,6 +253,10 @@ body {
&.ready {
background: var(--wxex-success);
}
&.connecting {
background: var(--wxex-brand);
}
}
.account-summary:not(.compact) .account-summary-avatar .account-summary-status {
@@ -306,6 +310,10 @@ body {
&.ready {
background: var(--wxex-success);
}
&.connecting {
background: var(--wxex-brand);
}
}
.account-summary-settings {