diff --git a/src/main/index.ts b/src/main/index.ts index 04fc512..a9bf5c3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -655,9 +655,10 @@ app.whenReady().then(async () => { return error ? { success: false, error } : { success: true } }) - ipcMain.handle('db:disconnect', () => { - if (!chat.isReady()) return { success: false, error: '数据库当前未连接' } - chat.setChatDb(null) + ipcMain.handle('db:disconnect', (_, options?: { closeNative?: boolean }) => { + // 断开操作保持幂等:渲染进程可能已标记断开,或主进程连接已先行失效。 + // 即使当前未就绪,也应让用户正常返回登录页。 + if (options?.closeNative !== false && chat.isReady()) chat.setChatDb(null) return { success: true } }) diff --git a/src/main/services/settings-store.ts b/src/main/services/settings-store.ts index 5029d20..5d6a61e 100644 --- a/src/main/services/settings-store.ts +++ b/src/main/services/settings-store.ts @@ -2,6 +2,7 @@ import { app } from 'electron' import fs from 'fs-extra' import path from 'path' import os from 'os' +import { discoverWindowsDbRoots } from '../windows-db-root-discovery' export interface AppSettings { dbRoot: string @@ -37,14 +38,7 @@ function getDefaultDbRootCandidates(home: string): string[] { path.join(os.homedir(), 'AppData', 'Roaming', 'Tencent', 'xwechat_files') ] - for (const drive of getWindowsDrives()) { - candidates.push(path.join(`${drive}:\\`, 'xwechat_files')) - candidates.push(path.join(`${drive}:\\`, 'WeChat Files')) - for (const child of listDirectories(`${drive}:\\`)) { - candidates.push(path.join(child, 'xwechat_files')) - candidates.push(path.join(child, 'WeChat Files')) - } - } + candidates.push(...discoverWindowsDbRoots()) return unique(candidates) } @@ -68,32 +62,6 @@ function getWeflowDbPathCandidates(home: string): string[] { return candidates } -function getWindowsDrives(): string[] { - const drives: string[] = [] - for (let code = 67; code <= 90; code += 1) { - const drive = String.fromCharCode(code) - if (fs.existsSync(`${drive}:\\`)) drives.push(drive) - } - return drives -} - -function listDirectories(root: string): string[] { - try { - return fs - .readdirSync(root) - .map((name) => path.join(root, name)) - .filter((candidate) => { - try { - return fs.statSync(candidate).isDirectory() - } catch { - return false - } - }) - } catch { - return [] - } -} - function unique(values: string[]): string[] { return Array.from(new Set(values)) } diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index e943752..876a3ce 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -1,6 +1,7 @@ import fs from 'fs-extra' import path from 'path' import os from 'os' +import { discoverWindowsDbRoots } from './windows-db-root-discovery' import crypto from 'crypto' import { createRequire } from 'module' import { createConnection, Socket } from 'net' @@ -303,14 +304,7 @@ export class Wcdb4Client { path.join(home, 'WeChat Files'), path.join(home, 'AppData', 'Roaming', 'Tencent', 'xwechat_files') ] - for (const drive of Wcdb4Client.getWindowsDrives()) { - candidates.push(path.join(`${drive}:\\`, 'xwechat_files')) - candidates.push(path.join(`${drive}:\\`, 'WeChat Files')) - for (const child of Wcdb4Client.listDirectories(`${drive}:\\`)) { - candidates.push(path.join(child, 'xwechat_files')) - candidates.push(path.join(child, 'WeChat Files')) - } - } + candidates.push(...discoverWindowsDbRoots()) return Array.from(new Set(candidates)) } return [path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')] @@ -335,32 +329,6 @@ export class Wcdb4Client { return candidates } - private static getWindowsDrives(): string[] { - const drives: string[] = [] - for (let code = 67; code <= 90; code += 1) { - const drive = String.fromCharCode(code) - if (fs.existsSync(`${drive}:\\`)) drives.push(drive) - } - return drives - } - - private static listDirectories(root: string): string[] { - try { - return fs - .readdirSync(root) - .map((name) => path.join(root, name)) - .filter((candidate) => { - try { - return fs.statSync(candidate).isDirectory() - } catch { - return false - } - }) - } catch { - return [] - } - } - private static hasDbStorage(candidate: string): boolean { try { return fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, 'db_storage')) diff --git a/src/main/windows-db-root-discovery.ts b/src/main/windows-db-root-discovery.ts new file mode 100644 index 0000000..aa74fb8 --- /dev/null +++ b/src/main/windows-db-root-discovery.ts @@ -0,0 +1,76 @@ +import fs from 'fs-extra' +import path from 'path' + +const DB_ROOT_NAMES = new Set(['xwechat_files', 'wechat files']) +const SKIPPED_DIRECTORY_NAMES = new Set([ + '$recycle.bin', + 'system volume information', + 'windows', + 'program files', + 'program files (x86)', + 'programdata', + 'recovery' +]) +const MAX_VISITED_DIRECTORIES_PER_DRIVE = 20_000 +let cachedDiscoveredRoots: string[] | null = null + +function unique(values: string[]): string[] { + return Array.from(new Set(values.map((value) => path.normalize(value)))) +} + +export function getWindowsDrives(): string[] { + if (process.platform !== 'win32') return [] + const drives: string[] = [] + for (let code = 67; code <= 90; code += 1) { + const root = `${String.fromCharCode(code)}:\\` + if (fs.existsSync(root)) drives.push(root) + } + return drives +} + +export function scanWindowsDbRoots(driveRoots: string[], maxDepth = 3): string[] { + const results: string[] = [] + + for (const driveRoot of driveRoots) { + const queue: Array<{ directory: string; depth: number }> = [{ directory: driveRoot, depth: 0 }] + let visited = 0 + + while (queue.length > 0 && visited < MAX_VISITED_DIRECTORIES_PER_DRIVE) { + const current = queue.shift() + if (!current || current.depth >= maxDepth) continue + + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(current.directory, { withFileTypes: true }) + } catch { + continue + } + + for (const entry of entries) { + if (!entry.isDirectory() || entry.isSymbolicLink()) continue + const lowered = entry.name.toLowerCase() + const fullPath = path.join(current.directory, entry.name) + const depth = current.depth + 1 + visited += 1 + + if (DB_ROOT_NAMES.has(lowered)) { + results.push(fullPath) + continue + } + if (depth < maxDepth && !SKIPPED_DIRECTORY_NAMES.has(lowered)) { + queue.push({ directory: fullPath, depth }) + } + if (visited >= MAX_VISITED_DIRECTORIES_PER_DRIVE) break + } + } + } + + return unique(results) +} + +export function discoverWindowsDbRoots(): string[] { + if (!cachedDiscoveredRoots) { + cachedDiscoveredRoots = scanWindowsDbRoots(getWindowsDrives(), 3) + } + return [...cachedDiscoveredRoots] +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 3519c27..aea3fd3 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -261,7 +261,9 @@ declare global { }> selectDbRoot: () => Promise<{ canceled: boolean; path?: string }> openAccountRoot: () => Promise<{ success: boolean; error?: string }> - disconnectDb: () => Promise<{ success: boolean; error?: string }> + disconnectDb: (options?: { + closeNative?: boolean + }) => Promise<{ success: boolean; error?: string }> apiStatus: () => Promise<{ running: boolean host: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 9a7a823..0173272 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -106,7 +106,8 @@ const api = { reopenWithRoot: (accountRoot: string) => ipcRenderer.invoke('db:reopenWithRoot', accountRoot), selectDbRoot: () => ipcRenderer.invoke('settings:selectDbRoot'), openAccountRoot: () => ipcRenderer.invoke('settings:openAccountRoot'), - disconnectDb: () => ipcRenderer.invoke('db:disconnect'), + disconnectDb: (options?: { closeNative?: boolean }) => + ipcRenderer.invoke('db:disconnect', options), apiStatus: () => ipcRenderer.invoke('api:getStatus'), apiStart: (host?: string, port?: number) => ipcRenderer.invoke('api:start', host, port), apiStop: () => ipcRenderer.invoke('api:stop'), @@ -120,7 +121,9 @@ const api = { // ============================================================ // AI 图片理解基础设施(ImageInsightService) // ============================================================ - imageListCandidates: (query: ImageCandidateQuery): Promise<{ + imageListCandidates: ( + query: ImageCandidateQuery + ): Promise<{ success: boolean candidates: ImageCandidate[] error?: string diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0cea872..cde51ef 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -19,6 +19,7 @@ import type { GeneratedReportRecord, ReportWorkspaceView } from './components/re import { AiModelConfig, useGroupReportGeneration } from './hooks/useGroupReportGeneration' import { SummaryDateRange, SummaryMessageType } from './utils/group-report' import { Contact, Message } from '../../shared/types' +import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/DatabaseConnectionPage' const SIDEBAR_MIN_WIDTH = 260 const SIDEBAR_MAX_WIDTH = 380 @@ -28,31 +29,6 @@ function getDevelopmentDatabaseKey(): string { return String(import.meta.env.VITE_DB_KEY || '').trim() } -function EyeIcon({ hidden }: { hidden: boolean }): React.ReactElement { - return ( - - ) -} - interface SelfInfo { wxid: string nickname: string @@ -173,6 +149,9 @@ function App(): React.ReactElement { const [showDbKey, setShowDbKey] = useState(false) const [dbRootInput, setDbRootInput] = useState('') const [showMacKeyFaq, setShowMacKeyFaq] = useState(false) + const [databaseConnectionMode, setDatabaseConnectionMode] = useState( + getDevelopmentDatabaseKey() ? 'manual' : 'automatic' + ) const [activePage, setActivePage] = useState('archive') const [settingsCategory, setSettingsCategory] = useState('account-database') const [reportSourceContact, setReportSourceContact] = useState(null) @@ -256,18 +235,25 @@ function App(): React.ReactElement { const waitForPaint = (): Promise => new Promise((resolve) => window.setTimeout(resolve, 80)) - const refreshSelfInfo = async (): Promise => { - try { - const result = await window.api.getSelf() - if (result.ready) { - setSelfInfo(result.info) - } else { - setSelfInfo(null) + const refreshSelfInfo = async (attempts = 1): Promise => { + let lastError: unknown + for (let attempt = 0; attempt < Math.max(1, attempts); attempt += 1) { + try { + const result = await window.api.getSelf() + if (result.ready && result.info) { + setSelfInfo(result.info) + return result.info + } + } catch (error) { + lastError = error + } + if (attempt + 1 < attempts) { + await new Promise((resolve) => window.setTimeout(resolve, 180)) } - } catch (error) { - console.warn('[SelfInfo] 加载失败:', error) - setSelfInfo(null) } + if (lastError) console.warn('[SelfInfo] 加载失败:', lastError) + setSelfInfo(null) + return null } const loadBootstrapCache = async (): Promise => { @@ -385,11 +371,15 @@ function App(): React.ReactElement { } const key = envKey || savedKey if (!key) { - if (active) setBootState('login') + if (active) { + setDatabaseConnectionMode('automatic') + setBootState('login') + } return } if (active) { setDbKey(key) + setDatabaseConnectionMode('manual') setAutoConnectSource(envKey ? 'env' : 'saved') setDbKeyStatus( autoLoginEnabled @@ -413,12 +403,12 @@ function App(): React.ReactElement { void window.api.setSettings({ autoLogin: true }) } setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true) - setIsAuthenticated(true) setIsDatabaseConnected(true) setDbKeyStatus('已自动连接') setDbKeyStatusKind('success') await loadContacts() - void refreshSelfInfo() + await refreshSelfInfo(3) + setIsAuthenticated(true) } else { const error = typeof result === 'boolean' ? '' : result.error setDbKeyStatus(`自动连接失败,请重新输入${error ? `: ${error}` : ''}`) @@ -488,7 +478,7 @@ function App(): React.ReactElement { detail: '正在读取本地缓存', percent: 25 }) - const hasBootstrapCache = await loadBootstrapCache() + await loadBootstrapCache() // 持久化手动输入的密钥,供下次启动继续使用 void window.api.saveDbKey(keyToUse).catch(() => undefined) void window.api.getSettings().then((current) => { @@ -499,10 +489,13 @@ function App(): React.ReactElement { setStartupProgress({ title: '正在加载账号信息...', subtitle: '即将进入 WechatExplorer', - detail: '正在读取当前账号信息', - percent: 95 + detail: '正在读取联系人和当前账号', + percent: 70 }) - void refreshSelfInfo() + // 账号识别依赖联系人数据就绪。返回登录后数据已被清空,如果先查账号, + // 会出现“数据库已连接,但账号未连接”的分离状态。手动连接与启动自动连接保持同一顺序。 + await loadContacts({ waitForAvatars: false }) + await refreshSelfInfo(3) setStartupProgress({ title: '加载完成', subtitle: '正在进入主页面', @@ -514,7 +507,6 @@ function App(): React.ReactElement { setBootState('login') window.setTimeout(() => { setStartupProgress(null) - if (!hasBootstrapCache) void loadContacts({ waitForAvatars: false }) }, 500) } else { const error = typeof result === 'boolean' ? '' : result.error @@ -619,6 +611,7 @@ function App(): React.ReactElement { throw new Error(result.error || '获取密钥失败') } setDbKey(result.key) + setDatabaseConnectionMode('manual') setDbKeyStatus(result.saved ? '密钥已获取并安全保存' : result.warning || '密钥已获取') setDbKeyStatusKind(result.saved ? 'success' : 'normal') } catch (error) { @@ -634,6 +627,7 @@ function App(): React.ReactElement { const result = await window.api.pasteAndSaveDbKey() if (result.success && result.key) { setDbKey(result.key) + setDatabaseConnectionMode('manual') setDbKeyStatus('已从剪贴板粘贴并安全保存') setDbKeyStatusKind('success') } else { @@ -651,6 +645,7 @@ function App(): React.ReactElement { return } setDbKey('') + setDatabaseConnectionMode('automatic') setDbKeyStatus('已清除保存的密钥') setDbKeyStatusKind('normal') } @@ -659,6 +654,7 @@ function App(): React.ReactElement { setIsAuthenticated(false) setIsDatabaseConnected(false) setBootState('login') + setDatabaseConnectionMode(dbKey ? 'manual' : 'automatic') setActivePage('archive') setSettingsCategory('database-key') setSelectedContact(null) @@ -1282,70 +1278,26 @@ function App(): React.ReactElement { if (!isAuthenticated) { return ( -
-
-

Enter WeChat DB Key

-
- setDbKey(e.target.value)} - placeholder="Key (e.g. 0x...)" - /> - -
- {window.electron.process.platform === 'win32' && ( -
- setDbRootInput(e.target.value)} - placeholder="微信聊天文件路径 (如 D:\\Tencent\\WeChat\\xwechat_files)" - spellCheck={false} - autoComplete="off" - /> -
- )} - - - - - {dbKeyStatus && ( -
{dbKeyStatus}
- )} - {showMacKeyFaq && ( - - 查看 macOS 获取密钥排障指引 - - )} -
-
+ setShowDbKey((visible) => !visible)} + onAutoGetKey={handleAutoGetDbKey} + onManualConnect={() => handleLogin()} + onPasteKey={handlePasteAndSaveDbKey} + onClearKey={handleClearSavedDbKey} + /> ) } diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 78be08d..582c05a 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1766,148 +1766,542 @@ body { line-height: 20px; } -.login-modal { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; +.database-login-page { + --login-primary: #247a63; + --login-primary-dark: #00604c; + --login-border: #d7dfda; + --login-surface: #f7faf6; + --login-surface-low: #f1f4f1; + --login-text: #181c1b; + --login-muted: #5f6965; + width: 100vw; + min-width: 760px; + height: 100vh; + min-height: 560px; + display: grid; + grid-template-columns: minmax(300px, 40%) 1fr; + overflow: hidden; + color: var(--login-text); + background: #fff; } -.login-box { - background-color: #fff; - padding: 20px; - border-radius: 8px; - width: 360px; - text-align: center; -} - -.login-input { - width: 100%; - padding: 8px; - margin: 10px 0; - border: 1px solid #ccc; - border-radius: 4px; -} - -.login-input-wrapper { +.database-login-brand { position: relative; display: flex; - align-items: center; + flex-direction: column; + padding: clamp(36px, 6vh, 72px) clamp(36px, 5vw, 72px) 28px; + background: var(--login-surface-low); + border-right: 1px solid var(--login-border); } -.login-input-wrapper .login-input { - padding-right: 40px; - margin: 10px 0 10px 0; +.database-login-brand-content { + width: min(360px, 100%); + margin: auto 0; } -.login-input-toggle { - position: absolute; - right: 8px; - width: 28px; - height: 28px; - display: inline-flex; - align-items: center; - justify-content: center; - background: none; - border: none; - cursor: pointer; - padding: 0; - color: #111827; -} - -.login-input-toggle svg { - width: 20px; - height: 20px; -} - -.login-input-toggle:hover { - color: #07c160; -} - -.login-btn { - background-color: #07c160; +.database-login-logo { + width: 56px; + height: 56px; + display: grid; + place-items: center; + margin-bottom: 24px; + border-radius: 10px; color: #fff; - border: none; - padding: 8px 20px; - border-radius: 4px; - cursor: pointer; - width: 100%; - margin-top: 8px; + background: var(--login-primary-dark); + box-shadow: 0 4px 12px rgba(0, 96, 76, 0.12); } -.login-btn:hover { - background-color: #06ad56; +.database-login-logo svg { + width: 29px; + height: 29px; } -.login-btn:disabled { - cursor: wait; - opacity: 0.65; +.database-login-brand h1 { + margin: 0; + font-size: 25px; + line-height: 32px; + font-weight: 650; + letter-spacing: -0.02em; + color: var(--login-primary-dark); } -.login-btn-secondary { - border: 1px solid #cfd5d8; +.database-login-tagline { + margin: 4px 0 26px; + color: var(--login-primary); + font-size: 14px; + line-height: 22px; + font-weight: 600; +} + +.database-login-description { + max-width: 320px; + margin: 0 0 30px; + color: var(--login-muted); + font-size: 13px; + line-height: 21px; +} + +.database-login-promises { + display: grid; + gap: 10px; +} + +.database-login-promises > div { + min-height: 44px; + display: flex; + align-items: center; + gap: 12px; + padding: 0 14px; + border: 1px solid rgba(190, 201, 195, 0.62); + border-radius: 7px; + color: #35423d; + background: rgba(255, 255, 255, 0.42); + font-size: 13px; + font-weight: 550; +} + +.database-login-promises svg { + width: 18px; + height: 18px; + color: var(--login-primary); +} + +.database-login-brand-footer { + margin-top: auto; + padding-top: 24px; + color: #78827e; + font-family: 'Work Sans', Inter, sans-serif; + font-size: 10px; + font-weight: 650; + letter-spacing: 0.11em; +} + +.database-login-workspace { + display: grid; + place-items: center; + min-width: 0; + padding: 48px clamp(40px, 7vw, 96px); background: #fff; - color: #30383d; } -.login-btn-secondary:hover { - border-color: #07c160; - background: #f2fbf6; - color: #078f49; +.database-login-panel { + width: min(520px, 100%); } -.login-clear-btn { - margin-top: 10px; +.database-login-tabs { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 4px; + padding: 4px; + margin-bottom: 28px; + border: 1px solid var(--login-border); + border-radius: 8px; + background: #e9eeeb; +} + +.database-login-tabs button { + min-height: 38px; border: 0; + border-radius: 5px; + color: #66716c; background: transparent; - color: #8a949a; cursor: pointer; - font-size: 12px; + font-size: 13px; + font-weight: 550; } -.login-clear-btn:hover { - color: #d33b3b; +.database-login-tabs button.active { + color: var(--login-primary-dark); + background: #fff; + box-shadow: 0 1px 3px rgba(24, 28, 27, 0.08); } -.login-key-status { - margin-top: 10px; - padding: 8px 10px; - border-radius: 6px; - background: #f3f5f6; - color: #59636a; +.database-login-state-card { + padding: 18px; + border: 1px solid var(--login-border); + border-radius: 9px; + background: var(--login-surface); +} + +.database-login-state-card.error { + border-color: #efc4be; + border-left: 3px solid #ba1a1a; + background: #fff9f8; +} + +.database-login-state-heading { + display: flex; + gap: 12px; + margin-bottom: 18px; +} + +.database-login-state-icon { + flex: 0 0 auto; + width: 30px; + height: 30px; + display: grid; + place-items: center; + border-radius: 50%; + color: var(--login-primary); + background: #e2f0eb; +} + +.database-login-state-card.error .database-login-state-icon { + color: #ba1a1a; + background: #ffebe8; +} + +.database-login-state-icon svg { + width: 17px; + height: 17px; +} + +.database-login-state-heading strong { + display: block; + margin: 1px 0 4px; + font-size: 14px; + line-height: 20px; +} + +.database-login-state-heading p { + margin: 0; + color: var(--login-muted); font-size: 12px; - line-height: 1.45; - text-align: left; + line-height: 18px; white-space: pre-wrap; word-break: break-word; } -.login-key-status.success { - background: #eefaf3; - color: #078f49; +.database-login-diagnostics { + margin: 0; + border-top: 1px solid #e2e7e4; } -.login-key-status.error { - background: #fff1f0; - color: #c73737; +.database-login-diagnostics > div { + min-height: 43px; + display: grid; + grid-template-columns: 112px 1fr; + align-items: center; + border-bottom: 1px solid #e2e7e4; } -.login-key-help-link { - display: inline-block; - margin-top: 9px; - color: #1677ff; +.database-login-diagnostics > div:last-child { + border-bottom: 0; +} + +.database-login-diagnostics dt { + display: flex; + align-items: center; + gap: 6px; + color: #6e7874; + font-family: 'Work Sans', Inter, sans-serif; + font-size: 10px; + font-weight: 650; + letter-spacing: 0.04em; +} + +.database-login-diagnostics dd { + min-width: 0; + margin: 0; + color: #35423d; font-size: 12px; + text-align: right; +} + +.database-login-path-input-wrap { + position: relative; + display: block; + width: 100%; +} + +.database-login-diagnostics input { + width: 100%; + height: 36px; + padding: 7px 0; + border: 0; + outline: 0; + color: #4c5752; + background: transparent; + font: inherit; + text-align: right; + text-overflow: ellipsis; +} + +.database-login-path-value { + position: absolute; + right: 0; + bottom: calc(100% + 7px); + z-index: 25; + width: min(520px, calc(100vw - 48px)); + padding: 9px 11px; + border: 1px solid #d7dfda; + border-radius: 7px; + color: #35423d; + background: #fff; + box-shadow: 0 6px 18px rgba(24, 28, 27, 0.13); + font-size: 11px; + line-height: 17px; + text-align: left; + overflow-wrap: anywhere; + opacity: 0; + pointer-events: none; + transform: translateY(4px); + transition: + opacity 120ms ease, + transform 120ms ease; +} + +.database-login-path-input-wrap:hover .database-login-path-value, +.database-login-path-input-wrap:focus-within .database-login-path-value { + opacity: 1; + transform: translateY(0); +} + +.database-login-primary, +.database-login-secondary { + width: 100%; + min-height: 44px; + margin-top: 22px; + border-radius: 7px; + cursor: pointer; + font-size: 13px; + font-weight: 600; +} + +.database-login-primary { + border: 1px solid var(--login-primary); + color: #fff; + background: var(--login-primary); + box-shadow: 0 2px 5px rgba(0, 96, 76, 0.16); +} + +.database-login-primary:hover:not(:disabled) { + border-color: var(--login-primary-dark); + background: var(--login-primary-dark); +} + +.database-login-primary:disabled { + cursor: not-allowed; + opacity: 0.52; + box-shadow: none; +} + +.database-login-auto > a { + display: block; + margin-top: 16px; + color: var(--login-primary); + font-size: 12px; + text-align: center; text-decoration: none; } -.login-key-help-link:hover { - text-decoration: underline; +.database-login-manual { + padding-top: 2px; +} + +.database-login-field { + margin-bottom: 20px; +} + +.database-login-field > label { + display: flex; + align-items: center; + gap: 7px; + margin-bottom: 8px; + color: #35423d; + font-size: 12px; + font-weight: 600; +} + +.database-login-path-help { + position: relative; + display: inline-flex; + align-items: center; +} + +.database-login-path-help-icon { + width: 15px; + height: 15px; + display: inline-grid; + place-items: center; + border: 1px solid #9a6b18; + border-radius: 50%; + color: #8a5d0b; + background: #fff8e6; + cursor: help; + font-family: Inter, sans-serif; + font-size: 10px; + font-weight: 700; + line-height: 1; +} + +.database-login-path-tooltip { + position: absolute; + left: 50%; + bottom: calc(100% + 9px); + z-index: 20; + width: 260px; + padding: 9px 11px; + border: 1px solid #d7dfda; + border-radius: 7px; + color: #35423d; + background: #fff; + box-shadow: 0 6px 18px rgba(24, 28, 27, 0.12); + font-family: Inter, sans-serif; + font-size: 11px; + font-weight: 400; + line-height: 17px; + letter-spacing: 0; + opacity: 0; + pointer-events: none; + transform: translate(-50%, 4px); + transition: + opacity 120ms ease, + transform 120ms ease; +} + +.database-login-path-help:hover .database-login-path-tooltip, +.database-login-path-help:focus-within .database-login-path-tooltip { + opacity: 1; + transform: translate(-50%, 0); +} + +.database-login-field > input, +.database-login-key-input { + width: 100%; + min-height: 42px; + border: 1px solid var(--login-border); + border-radius: 7px; + background: #fff; +} + +.database-login-field > input { + padding: 0 12px; + color: var(--login-text); + font-size: 13px; + outline: none; +} + +.database-login-key-input { + display: flex; + align-items: center; +} + +.database-login-key-input:focus-within, +.database-login-field > input:focus { + border-color: var(--login-primary); + box-shadow: 0 0 0 3px rgba(36, 122, 99, 0.1); +} + +.database-login-key-input input { + flex: 1; + min-width: 0; + height: 40px; + padding: 0 4px 0 12px; + border: 0; + outline: 0; + color: var(--login-text); + background: transparent; + font-size: 13px; +} + +.database-login-key-input button { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border: 0; + color: #65706b; + background: transparent; + cursor: pointer; +} + +.database-login-key-input svg { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; +} + +.database-login-field small { + display: block; + margin-top: 7px; + color: #77817d; + font-size: 11px; +} + +.database-login-message { + padding: 10px 12px; + border-radius: 7px; + color: #57625d; + background: var(--login-surface-low); + font-size: 12px; + line-height: 18px; + white-space: pre-wrap; +} + +.database-login-message.success { + color: var(--login-primary-dark); + background: #edf7f3; +} + +.database-login-message.error { + color: #93000a; + background: #fff0ee; +} + +.database-login-secondary { + margin-top: 10px; + border: 1px solid var(--login-border); + color: #3f4945; + background: #fff; +} + +.database-login-secondary:hover { + border-color: var(--login-primary); + color: var(--login-primary-dark); + background: var(--login-surface); +} + +.database-login-footer-actions { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 42px; + padding-top: 16px; + border-top: 1px solid #e5e9e6; + color: #8a938f; + font-size: 11px; +} + +.database-login-footer-actions button { + padding: 0; + border: 0; + color: #78827e; + background: transparent; + cursor: pointer; + font-size: 11px; +} + +.database-login-footer-actions button:hover { + color: #93000a; +} + +@media (max-width: 900px), (max-height: 620px) { + .database-login-brand { + padding: 30px; + } + + .database-login-description { + margin-bottom: 20px; + } + + .database-login-workspace { + padding: 30px; + } } .modal-overlay { diff --git a/src/renderer/src/components/DatabaseConnectionPage.tsx b/src/renderer/src/components/DatabaseConnectionPage.tsx new file mode 100644 index 0000000..032e75c --- /dev/null +++ b/src/renderer/src/components/DatabaseConnectionPage.tsx @@ -0,0 +1,300 @@ +import React from 'react' + +export type DatabaseConnectionMode = 'automatic' | 'manual' +export type DatabaseConnectionStatusKind = 'normal' | 'success' | 'error' + +interface DatabaseConnectionPageProps { + platform: string + mode: DatabaseConnectionMode + dbKey: string + dbRoot: string + showDbKey: boolean + isFetching: boolean + status: string + statusKind: DatabaseConnectionStatusKind + showMacKeyFaq: boolean + macKeyFaqUrl: string + onModeChange: (mode: DatabaseConnectionMode) => void + onDbKeyChange: (value: string) => void + onDbRootChange: (value: string) => void + onToggleDbKey: () => void + onAutoGetKey: () => void + onManualConnect: () => void + onPasteKey: () => void + onClearKey: () => void +} + +function LineIcon({ + name +}: { + name: 'shield' | 'lock' | 'cloud' | 'info' | 'database' +}): React.ReactElement { + const paths = { + shield: , + lock: , + cloud: ( + + ), + info: , + database: ( + + ) + } + return ( + + ) +} + +function EyeIcon({ visible }: { visible: boolean }): React.ReactElement { + return ( + + ) +} + +function StoragePathHelp(): React.ReactElement { + return ( + + + ! + + + 打开微信设置,在缓存管理中复制存储路径,然后粘贴到这里。 + + + ) +} + +export function DatabaseConnectionPage({ + platform, + mode, + dbKey, + dbRoot, + showDbKey, + isFetching, + status, + statusKind, + showMacKeyFaq, + macKeyFaqUrl, + onModeChange, + onDbKeyChange, + onDbRootChange, + onToggleDbKey, + onAutoGetKey, + onManualConnect, + onPasteKey, + onClearKey +}: DatabaseConnectionPageProps): React.ReactElement { + const isMac = platform === 'darwin' + const defaultPath = isMac + ? '~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/' + : 'C:\\Users\\...\\WeChat Files\\Msg' + const keyIsValid = /^[0-9a-f]{64}$/i.test(dbKey.trim().replace(/^0x/i, '')) + + return ( +
+
+
+ +

WechatExplorer

+

你的本地微信聊天档案

+

+ 连接本机微信数据库,开始检索、整理和分析聊天记录。 +

+
+
+ + 仅限本机 +
+
+ + 加密保存 +
+
+ + 不会上传 +
+
+
+
LOCAL-FIRST · PRIVATE · SECURE
+
+ +
+
+
+ + +
+ + {mode === 'automatic' ? ( +
+
+
+ + + +
+ + {statusKind === 'error' ? '未能获取数据库密钥' : '已准备检测微信数据库'} + +

+ {statusKind === 'error' + ? status + : status || '请保持微信客户端正在运行,系统将尝试安全获取数据库密钥。'} +

+
+
+
+
+
微信客户端
+
{isFetching ? '正在检测' : '等待检测'}
+
+
+
+ 存储路径 + +
+
+ + onDbRootChange(event.target.value)} + placeholder={defaultPath} + title={dbRoot || defaultPath} + aria-label="微信数据存储路径" + spellCheck={false} + onFocus={(event) => event.currentTarget.select()} + /> + + {dbRoot || defaultPath} + + +
+
+
+
数据库状态
+
{statusKind === 'error' ? '无法连接' : '准备连接'}
+
+
+
+ + {showMacKeyFaq && ( + + 查看详情 · 连接帮助 + + )} +
+ ) : ( +
+
+ +
+ onDbKeyChange(event.target.value)} + placeholder="输入或粘贴 64 位数据库密钥" + autoComplete="off" + spellCheck={false} + /> + +
+ 密钥通过系统安全存储加密保存在当前设备。 +
+ {platform === 'win32' && ( +
+ + onDbRootChange(event.target.value)} + placeholder={defaultPath} + title={dbRoot || defaultPath} + spellCheck={false} + onFocus={(event) => event.currentTarget.select()} + /> +
+ )} + {status &&
{status}
} + + +
+ )} + +
+ + WechatExplorer +
+
+
+
+ ) +} diff --git a/src/renderer/src/features/settings/database-key/useDatabaseKeyController.ts b/src/renderer/src/features/settings/database-key/useDatabaseKeyController.ts index 0dbbb08..a58d358 100644 --- a/src/renderer/src/features/settings/database-key/useDatabaseKeyController.ts +++ b/src/renderer/src/features/settings/database-key/useDatabaseKeyController.ts @@ -191,7 +191,9 @@ export function useDatabaseKeyController({ ]) const returnToLogin = useCallback(async (): Promise => { - const result = await window.api.disconnectDb() + // macOS WCDB 的 close 会关闭进程级原生运行时,返回登录时只退出 UI 连接态。 + // 用户再次点击连接后由 db:init 复用已验证的本机连接。 + const result = await window.api.disconnectDb({ closeNative: false }) if (!result.success) { onNotice(result.error || '断开数据库连接失败') return