From ab2418567042c8505287d5d111ccae13cb419350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B5=E6=91=87=E5=B0=8F=E5=AD=90?= Date: Mon, 13 Jul 2026 14:52:10 +0800 Subject: [PATCH] =?UTF-8?q?=E7=B2=BE=E7=AE=80=E8=B4=A6=E5=8F=B7=E4=B8=8E?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E8=AE=BE=E7=BD=AE=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/src/App.tsx | 7 - src/renderer/src/assets/main.css | 72 ++++++- .../features/settings/SettingsWorkspace.tsx | 3 - .../account-database/AccountOverview.tsx | 106 ++++++++++ .../ConnectionHealthSection.tsx | 37 ++++ .../account-database/LocalPrivacyNotice.tsx | 20 ++ .../settings/account-database/diagnostics.ts | 154 ++++++++++++++ .../settings/account-database/types.ts | 18 ++ .../useAccountDatabaseController.ts | 161 +++++++------- .../src/features/settings/model/types.ts | 10 - .../settings/pages/AccountDatabasePage.tsx | 200 +++--------------- .../utils/buildConnectionDiagnostics.ts | 43 ---- 12 files changed, 524 insertions(+), 307 deletions(-) create mode 100644 src/renderer/src/features/settings/account-database/AccountOverview.tsx create mode 100644 src/renderer/src/features/settings/account-database/ConnectionHealthSection.tsx create mode 100644 src/renderer/src/features/settings/account-database/LocalPrivacyNotice.tsx create mode 100644 src/renderer/src/features/settings/account-database/diagnostics.ts create mode 100644 src/renderer/src/features/settings/account-database/types.ts delete mode 100644 src/renderer/src/features/settings/utils/buildConnectionDiagnostics.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index bbdd972..1ce6340 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -810,12 +810,6 @@ function App(): React.ReactElement { setActivePage('settings') } - const handleSettingsConnectionChanged = (): void => { - void refreshSelfInfo() - void loadContacts() - void window.api.getSelf().then((result) => setIsDatabaseConnected(result.ready)) - } - const openReport = (reportId: string): void => { setSelectedReportId(reportId) setReportWorkspaceView('result') @@ -1112,7 +1106,6 @@ function App(): React.ReactElement { selfInfo={selfInfo} dbReady={isDatabaseConnected} dbKey={dbKey} - onConnectionChanged={handleSettingsConnectionChanged} onNotice={(message) => { setReportNotice(message) window.setTimeout(() => setReportNotice(''), 3200) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 1ec418c..8743494 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -3554,9 +3554,75 @@ body { .settings-sidebar { width:294px; flex:0 0 294px; min-width:0; min-height:0; display:flex; flex-direction:column; background:#eef2f0; border-right:1px solid #dde3e0; } .settings-sidebar header { padding:22px 20px 16px; border-bottom:1px solid #dde3e0; }.settings-sidebar h1 { margin:0; color:#202724; font-size:20px; }.settings-sidebar header p { margin:5px 0 16px; color:#66706b; font-size:12px; }.settings-search { height:34px; display:flex; align-items:center; gap:8px; padding:0 10px; border:1px solid #dde3e0; border-radius:8px; background:#fff; color:#66706b; }.settings-search input { min-width:0; width:100%; border:0; outline:0; color:#202724; background:transparent; font:inherit; font-size:12px; } .settings-sidebar-list { flex:1; min-height:0; overflow:auto; padding:12px 8px; }.settings-sidebar-list section { margin:0 0 18px; }.settings-sidebar-list h2 { margin:0 12px 6px; color:#929a96; font-size:12px; font-weight:500; }.settings-sidebar-list button { position:relative; display:block; width:100%; border:0; padding:9px 12px; background:transparent; color:#39433e; text-align:left; font:600 13px inherit; cursor:pointer; border-radius:8px; }.settings-sidebar-list button.active { background:#fff; color:#202724; }.settings-sidebar-list button.active::before { content:''; position:absolute; left:0; top:7px; bottom:7px; width:3px; border-radius:0 3px 3px 0; background:#247a63; }.settings-sidebar-account { flex:0 0 auto; padding:10px; border-top:1px solid #dde3e0; } -.settings-page { position:relative; display:flex; flex:1; min-width:0; min-height:0; flex-direction:column; }.settings-page-header { flex:0 0 auto; display:flex; justify-content:space-between; align-items:flex-start; padding:24px 34px; border-bottom:1px solid #eef1ef; background:#fafbfa; }.settings-page-header h1 { margin:0; color:#202724; font-size:21px; }.settings-page-header p { margin:6px 0 0; color:#66706b; font-size:13px; }.settings-status-badge { margin-top:5px; border-radius:16px; padding:7px 12px; background:#edf5f1; color:#2e8b68; font-size:12px; }.settings-status-badge::before { content:'●'; margin-right:6px; font-size:9px; }.settings-status-badge.error { background:#f9eeee; color:#c85a5a; }.settings-status-badge.unavailable { background:#f1f3f2; color:#66706b; } -.settings-page-scroll { flex:1; min-height:0; overflow-y:auto; overflow-x:hidden; }.settings-page-content { max-width:820px; padding:34px 34px 64px; }.settings-privacy-notice { display:flex; gap:14px; align-items:flex-start; padding:17px; border:1px solid #bfded3; border-radius:10px; background:#f0f7f4; color:#36564d; }.settings-privacy-notice svg { flex:0 0 24px; width:24px; stroke:#247a63; fill:none; stroke-width:1.8; }.settings-privacy-notice strong { color:#294b41; font-size:14px; }.settings-privacy-notice p { margin:5px 0 0; color:#66706b; font-size:13px; line-height:1.6; }.settings-section-heading { margin:30px 0 14px; color:#3d4742; font-size:16px; }.settings-section-heading.danger { color:#c85a5a; }.settings-card { border:1px solid #dde3e0; border-radius:10px; background:#fff; padding:24px 26px; }.settings-account-overview { display:grid; grid-template-columns:66px minmax(120px,1fr) minmax(120px,1fr) auto; gap:24px; align-items:center; }.settings-avatar { width:66px; height:66px; overflow:hidden; display:grid; place-items:center; border-radius:9px; background:#dcede6; color:#247a63; font-size:23px; }.settings-avatar img { width:100%; height:100%; object-fit:cover; }.settings-account-meta { display:grid; gap:4px; min-width:0; }.settings-account-meta span,.settings-field-label { color:#929a96; font-size:12px; }.settings-account-meta strong { overflow:hidden; color:#35403b; font-size:13px; text-overflow:ellipsis; white-space:nowrap; }.success-text { color:#2e8b68 !important; }.settings-account-actions { display:grid; gap:8px; }.settings-path-row { display:flex; gap:10px; align-items:center; margin-top:10px; }.settings-path-row code { min-width:0; flex:1; overflow:hidden; padding:11px 13px; border:1px solid #dde3e0; border-radius:8px; background:#f2f5f3; color:#46514c; text-overflow:ellipsis; white-space:nowrap; font-size:12px; }.settings-apply-row { padding:16px 0 20px; border-bottom:1px solid #edf0ee; }.settings-diagnostic-header { display:flex; justify-content:space-between; padding:20px 0 12px; color:#66706b; font-size:13px; }.settings-text-button { border:0; background:transparent; color:#247a63; cursor:pointer; font:inherit; }.settings-diagnostics { display:grid; gap:8px; }.settings-diagnostic { display:flex; align-items:center; gap:12px; min-width:0; padding:13px; border:1px solid #edf0ee; border-radius:8px; }.settings-diagnostic-icon { display:grid; place-items:center; width:18px; height:18px; border:1px solid #929a96; border-radius:50%; color:#929a96; font-size:11px; }.settings-diagnostic-icon.ok { border-color:#2e8b68; color:#2e8b68; }.settings-diagnostic span { color:#3d4742; font-size:13px; }.settings-diagnostic small { margin-left:auto; overflow:hidden; color:#66706b; text-overflow:ellipsis; white-space:nowrap; }.settings-danger-card { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:18px; border-color:#f0c9c9; background:#fffafa; }.settings-danger-card strong { color:#4c3e3e; font-size:14px; }.settings-danger-card p { margin:6px 0 0; color:#7b7474; font-size:12px; }.settings-danger-divider { grid-column:1/-1; height:1px; background:#f2dddd; }.settings-danger-button { align-self:center; border:1px solid #e3a6a6; border-radius:8px; padding:9px 13px; background:#fffafa; color:#c85a5a; cursor:pointer; font:600 13px inherit; }.settings-danger-button:disabled { opacity:.55; cursor:not-allowed; }.settings-confirm-overlay { position:absolute; z-index:5; inset:0; display:grid; place-items:center; background:rgba(32,39,36,.22); }.settings-confirm { width:min(380px,calc(100% - 32px)); padding:22px; border:1px solid #dde3e0; border-radius:10px; background:#fff; box-shadow:0 12px 30px rgba(33,44,39,.16); }.settings-confirm h2 { margin:0; font-size:17px; }.settings-confirm p { color:#66706b; font-size:13px; line-height:1.6; }.settings-confirm div { display:flex; justify-content:flex-end; gap:8px; }.settings-empty-state { display:grid; flex:1; place-content:center; color:#66706b; text-align:center; }.settings-empty-state h2 { color:#202724; } -@media (max-width:900px) { .settings-sidebar { width:248px; flex-basis:248px; }.settings-page-content { padding:24px; }.settings-page-header { padding:20px 24px; }.settings-account-overview { grid-template-columns:56px 1fr; gap:16px; }.settings-account-actions { grid-column:1/-1; grid-template-columns:repeat(2,max-content); } } +.settings-page { position:relative; display:flex; flex:1; min-width:0; min-height:0; flex-direction:column; } +.settings-page-header { flex:0 0 auto; display:flex; justify-content:space-between; align-items:flex-start; gap:18px; padding:24px 34px; border-bottom:1px solid #eef1ef; background:#fafbfa; } +.settings-page-header h1 { margin:0; color:#202724; font-size:21px; } +.settings-page-header p { margin:6px 0 0; color:#66706b; font-size:13px; } +.settings-status-badge { margin-top:5px; flex:0 0 auto; border-radius:16px; padding:7px 12px; background:#edf5f1; color:#2e8b68; font-size:12px; } +.settings-status-badge::before { content:'●'; margin-right:6px; font-size:9px; } +.settings-status-badge.checking { background:#eef4f8; color:#4f7188; } +.settings-status-badge.warning { background:#fff6e8; color:#a56a24; } +.settings-status-badge.error { background:#f9eeee; color:#c85a5a; } +.settings-status-badge.unavailable { background:#f1f3f2; color:#66706b; } +.settings-page-scroll { flex:1; min-height:0; overflow-y:auto; overflow-x:hidden; } +.settings-page-content { box-sizing:border-box; width:min(100%, 860px); padding:34px 34px 64px; } +.settings-privacy-notice { display:flex; gap:14px; align-items:flex-start; padding:17px; border:1px solid #bfded3; border-radius:10px; background:#f0f7f4; color:#36564d; } +.settings-privacy-notice svg { flex:0 0 24px; width:24px; stroke:#247a63; fill:none; stroke-width:1.8; } +.settings-privacy-notice strong { color:#294b41; font-size:14px; } +.settings-privacy-notice p { margin:5px 0 0; color:#66706b; font-size:13px; line-height:1.6; } +.settings-section-heading { margin:30px 0 14px; color:#3d4742; font-size:16px; } +.settings-card { border:1px solid #dde3e0; border-radius:10px; background:#fff; padding:24px 26px; } +.settings-account-overview { display:grid; grid-template-columns:minmax(190px,1fr) minmax(220px,1fr) auto; gap:24px; align-items:center; } +.settings-account-primary { display:flex; min-width:0; align-items:center; gap:15px; } +.settings-avatar { width:66px; height:66px; flex:0 0 auto; overflow:hidden; display:grid; place-items:center; border-radius:9px; background:#dcede6; color:#247a63; font-size:23px; } +.settings-avatar img { width:100%; height:100%; object-fit:cover; } +.settings-account-identity { display:grid; min-width:0; gap:3px; } +.settings-account-identity span,.settings-account-root>span,.settings-account-facts dt { color:#929a96; font-size:12px; } +.settings-account-identity strong,.settings-account-identity small { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.settings-account-identity strong { color:#35403b; font-size:14px; } +.settings-account-identity small { color:#66706b; font:12px/18px ui-monospace,SFMono-Regular,Consolas,monospace; } +.settings-account-facts { display:grid; min-width:0; gap:12px; margin:0; } +.settings-account-facts div { min-width:0; } +.settings-account-facts dd { overflow:hidden; margin:3px 0 0; color:#35403b; font-size:13px; text-overflow:ellipsis; white-space:nowrap; } +.settings-connection-text.success { color:#2e8b68; } +.settings-connection-text.warning { color:#a56a24; } +.settings-connection-text.error { color:#c85a5a; } +.settings-account-actions { display:grid; gap:8px; } +.settings-account-actions button { min-height:34px; white-space:nowrap; } +.settings-account-root { grid-column:1/-1; display:grid; min-width:0; gap:7px; padding-top:19px; border-top:1px solid #edf0ee; } +.settings-account-root>div { display:flex; min-width:0; align-items:center; gap:8px; } +.settings-account-root code { min-width:0; flex:1; overflow:hidden; padding:10px 12px; border:1px solid #dde3e0; border-radius:8px; background:#f2f5f3; color:#46514c; text-overflow:ellipsis; white-space:nowrap; font:12px/18px ui-monospace,SFMono-Regular,Consolas,monospace; } +.settings-icon-button { display:grid; width:36px; height:36px; flex:0 0 auto; place-items:center; border:1px solid #dde3e0; border-radius:8px; background:#fff; color:#66706b; cursor:pointer; } +.settings-icon-button:hover:not(:disabled) { border-color:#247a63; color:#247a63; } +.settings-icon-button:disabled { cursor:not-allowed; opacity:.45; } +.settings-icon-button svg { width:17px; fill:none; stroke:currentColor; stroke-linecap:round; stroke-linejoin:round; stroke-width:1.8; } +.settings-health-card { padding:12px; } +.settings-diagnostic-summary { margin:0 0 10px; padding:9px 11px; border-left:3px solid #c68635; border-radius:6px; background:#fff8ec; color:#79501f; font-size:12px; line-height:1.55; } +.settings-diagnostics { display:grid; gap:8px; } +.settings-diagnostic { display:flex; align-items:center; gap:12px; min-width:0; padding:13px; border:1px solid #edf0ee; border-radius:8px; } +.settings-diagnostic-icon { display:grid; width:18px; height:18px; flex:0 0 auto; place-items:center; border:1px solid #929a96; border-radius:50%; color:#929a96; font-size:11px; font-weight:700; } +.settings-diagnostic-icon.success { border-color:#2e8b68; color:#2e8b68; } +.settings-diagnostic-icon.warning { border-color:#c68635; color:#c68635; } +.settings-diagnostic-icon.error { border-color:#c85a5a; color:#c85a5a; } +.settings-diagnostic-icon.checking { border-color:#cbd7d2; border-top-color:#247a63; animation:settings-spin .8s linear infinite; } +.settings-diagnostic span { color:#3d4742; font-size:13px; } +.settings-diagnostic small { margin-left:auto; overflow:hidden; color:#66706b; text-overflow:ellipsis; white-space:nowrap; } +.settings-empty-state { display:grid; flex:1; place-content:center; color:#66706b; text-align:center; } +.settings-empty-state h2 { color:#202724; } +@keyframes settings-spin { to { transform:rotate(360deg); } } +@media (max-width:900px) { + .settings-sidebar { width:248px; flex-basis:248px; } + .settings-page-content { padding:24px; } + .settings-page-header { padding:20px 24px; } + .settings-account-overview { grid-template-columns:minmax(0,1fr) auto; gap:18px; } + .settings-account-facts { grid-column:1/-1; grid-template-columns:repeat(2,minmax(0,1fr)); } + .settings-account-actions { grid-row:1; grid-column:2; } +} +@media (max-width:700px) { + .settings-account-overview { grid-template-columns:minmax(0,1fr); } + .settings-account-actions { grid-row:auto; grid-column:1; grid-template-columns:repeat(2,minmax(0,1fr)); } + .settings-account-root { grid-column:1; } +} .api-center-layout > * { min-width: 0; min-height: 0; box-sizing: border-box; } .api-section-heading h2, .api-introduction h2, .api-integrations h2, .api-runtime-title h2 { margin: 0; color: var(--wxex-text-primary); font: 700 17px/24px var(--wxex-font); } .ready-text { color: var(--wxex-success); } diff --git a/src/renderer/src/features/settings/SettingsWorkspace.tsx b/src/renderer/src/features/settings/SettingsWorkspace.tsx index efe3066..fdd5ce8 100644 --- a/src/renderer/src/features/settings/SettingsWorkspace.tsx +++ b/src/renderer/src/features/settings/SettingsWorkspace.tsx @@ -10,7 +10,6 @@ export function SettingsWorkspace({ selfInfo, dbReady, dbKey, - onConnectionChanged, onNotice, onOpenSettings }: { @@ -19,7 +18,6 @@ export function SettingsWorkspace({ selfInfo: SettingsSelfInfo | null dbReady: boolean dbKey: string - onConnectionChanged: () => void onNotice: (message: string) => void onOpenSettings: () => void }): React.ReactElement { @@ -37,7 +35,6 @@ export function SettingsWorkspace({ dbKey={dbKey} dbReady={dbReady} selfInfo={selfInfo} - onConnectionChanged={onConnectionChanged} onNotice={onNotice} /> ) : ( diff --git a/src/renderer/src/features/settings/account-database/AccountOverview.tsx b/src/renderer/src/features/settings/account-database/AccountOverview.tsx new file mode 100644 index 0000000..23c5a56 --- /dev/null +++ b/src/renderer/src/features/settings/account-database/AccountOverview.tsx @@ -0,0 +1,106 @@ +import type { SettingsSelfInfo } from '../model/types' +import type { ConnectionOverviewStatus } from './types' + +function CopyIcon(): React.ReactElement { + return ( + + ) +} + +const CONNECTION_LABELS: Record = { + checking: '正在检测', + success: '连接正常', + warning: '部分能力不可用', + error: '连接异常', + unavailable: '尚未连接' +} + +export function AccountOverview({ + selfInfo, + connectionStatus, + lastCheckedLabel, + isChecking, + onCheck, + onOpenDirectory, + onCopyDirectory +}: { + selfInfo: SettingsSelfInfo | null + connectionStatus: ConnectionOverviewStatus + lastCheckedLabel: string + isChecking: boolean + onCheck: () => void + onOpenDirectory: () => void + onCopyDirectory: () => void +}): React.ReactElement { + const accountRoot = selfInfo?.accountRoot || '' + return ( +
+
+
+ {selfInfo?.avatar ? ( + 当前账号 + ) : ( + (selfInfo?.nickname || '?').charAt(0) + )} +
+
+ 昵称 + {selfInfo?.nickname || '暂无数据'} + {selfInfo?.wxid || '暂无 WXID'} +
+
+ +
+
+
数据库连接状态
+
+ {CONNECTION_LABELS[connectionStatus]} +
+
+
+
最近检测时间
+
{lastCheckedLabel}
+
+
+ +
+ + +
+ +
+ 当前账号目录 +
+ {accountRoot || '暂无数据'} + +
+
+
+ ) +} diff --git a/src/renderer/src/features/settings/account-database/ConnectionHealthSection.tsx b/src/renderer/src/features/settings/account-database/ConnectionHealthSection.tsx new file mode 100644 index 0000000..a460193 --- /dev/null +++ b/src/renderer/src/features/settings/account-database/ConnectionHealthSection.tsx @@ -0,0 +1,37 @@ +import type { ConnectionDiagnostic, DiagnosticStatus } from './types' + +function DiagnosticIcon({ status }: { status: DiagnosticStatus }): React.ReactElement { + if (status === 'checking') { + return + } + const symbol = + status === 'success' ? '✓' : status === 'warning' ? '!' : status === 'error' ? '×' : '—' + return ( + + ) +} + +export function ConnectionHealthSection({ + diagnostics, + summary +}: { + diagnostics: ConnectionDiagnostic[] + summary?: string +}): React.ReactElement { + return ( +
+ {summary &&

{summary}

} +
+ {diagnostics.map((item) => ( +
+ + {item.label} + {item.result} +
+ ))} +
+
+ ) +} diff --git a/src/renderer/src/features/settings/account-database/LocalPrivacyNotice.tsx b/src/renderer/src/features/settings/account-database/LocalPrivacyNotice.tsx new file mode 100644 index 0000000..2afad0c --- /dev/null +++ b/src/renderer/src/features/settings/account-database/LocalPrivacyNotice.tsx @@ -0,0 +1,20 @@ +function ShieldIcon(): React.ReactElement { + return ( + + ) +} + +export function LocalPrivacyNotice(): React.ReactElement { + return ( +
+ +
+ 数据仅在本机读取 +

WechatExplorer 不会将您的微信聊天数据上传到云端。所有解析和存储均在本地完成。

+
+
+ ) +} diff --git a/src/renderer/src/features/settings/account-database/diagnostics.ts b/src/renderer/src/features/settings/account-database/diagnostics.ts new file mode 100644 index 0000000..feb5761 --- /dev/null +++ b/src/renderer/src/features/settings/account-database/diagnostics.ts @@ -0,0 +1,154 @@ +import type { SettingsSelfInfo } from '../model/types' +import type { ConnectionCheckState, ConnectionDiagnostic, ConnectionOverviewStatus } from './types' + +const CORE_DIAGNOSTIC_IDS = new Set([ + 'db-key', + 'contacts', + 'messages', + 'identity' +]) + +export function buildConnectionDiagnostics(input: { + dbReady: boolean + selfInfo: SettingsSelfInfo | null + hasDbKey: boolean + hasImageKey: boolean + checkState: ConnectionCheckState +}): ConnectionDiagnostic[] { + if (input.checkState.status === 'checking') { + return [ + { id: 'db-key', label: '数据库密钥', status: 'checking', result: '检测中' }, + { id: 'contacts', label: '联系人索引', status: 'checking', result: '检测中' }, + { id: 'messages', label: '消息数据库', status: 'checking', result: '检测中' }, + { id: 'identity', label: '账号身份', status: 'checking', result: '检测中' }, + { id: 'image-key', label: '图片解密密钥', status: 'checking', result: '检测中' } + ] + } + + const checkFailed = input.checkState.status === 'error' + const checkError = input.checkState.status === 'error' ? input.checkState.message : undefined + const connected = input.dbReady && !checkFailed + const identityMatched = + input.checkState.status === 'success' || input.checkState.status === 'warning' + ? input.checkState.identityMatched + : undefined + + return [ + { + id: 'db-key', + label: '数据库密钥', + status: checkFailed + ? 'error' + : connected + ? 'success' + : input.hasDbKey + ? 'warning' + : 'unavailable', + result: checkFailed + ? '验证失败' + : connected + ? '已用于当前连接' + : input.hasDbKey + ? '尚未验证' + : '尚未配置', + detail: checkError + }, + { + id: 'contacts', + label: '联系人索引', + status: checkFailed ? 'error' : connected ? 'success' : 'idle', + result: checkFailed ? '读取失败' : connected ? '当前连接可读取' : '未检测' + }, + { + id: 'messages', + label: '消息数据库', + status: checkFailed ? 'error' : connected ? 'success' : 'idle', + result: checkFailed ? '挂载失败' : connected ? '当前连接已挂载' : '未检测' + }, + { + id: 'identity', + label: '账号身份', + status: + identityMatched === false + ? 'error' + : connected && input.selfInfo?.wxid + ? 'success' + : 'idle', + result: + identityMatched === false + ? '账号不匹配' + : connected && input.selfInfo?.wxid + ? '匹配成功' + : '未检测' + }, + { + id: 'image-key', + label: '图片解密密钥', + status: input.hasImageKey ? 'success' : 'warning', + result: input.hasImageKey ? '已配置' : '未配置', + detail: input.hasImageKey ? undefined : '不影响聊天数据库读取,可在图片解密设置中配置' + } + ] +} + +export function getConnectionOverviewStatus(input: { + dbReady: boolean + checkState: ConnectionCheckState + diagnostics: ConnectionDiagnostic[] +}): ConnectionOverviewStatus { + if (input.checkState.status === 'checking') return 'checking' + if (!input.dbReady) return 'unavailable' + if (input.checkState.status === 'error') return 'error' + + const coreDiagnostics = input.diagnostics.filter((item) => CORE_DIAGNOSTIC_IDS.has(item.id)) + if (coreDiagnostics.some((item) => item.status === 'error')) return 'error' + if (coreDiagnostics.some((item) => item.status !== 'success')) return 'warning' + if ( + input.diagnostics.some((item) => item.status === 'warning' || item.status === 'unavailable') + ) { + return 'warning' + } + return 'success' +} + +export function formatConnectionCheckedAt( + checkState: ConnectionCheckState, + now: Date = new Date() +): string { + if (checkState.status === 'checking') return '正在检测' + if (!checkState.checkedAt) return '本次启动期间尚未检测' + + const elapsedMinutes = Math.max( + 0, + Math.floor((now.getTime() - checkState.checkedAt.getTime()) / 60_000) + ) + if (elapsedMinutes < 1) return '刚刚' + if (elapsedMinutes < 60) return `${elapsedMinutes} 分钟前` + return checkState.checkedAt.toLocaleString('zh-CN', { hour12: false }) +} + +export function isSameAccount(input: { + currentWxid?: string + probeWxid?: string + currentRoot?: string + probeRoot?: string +}): boolean | undefined { + const currentWxid = input.currentWxid?.trim() + const probeWxid = input.probeWxid?.trim() + if (currentWxid && probeWxid) return currentWxid === probeWxid + + const normalizePath = (value?: string): string | undefined => + value?.trim().replace(/\\/g, '/').replace(/\/+$/, '').toLocaleLowerCase() + const currentRoot = normalizePath(input.currentRoot) + const probeRoot = normalizePath(input.probeRoot) + return currentRoot && probeRoot ? currentRoot === probeRoot : undefined +} + +export function sanitizeConnectionError(error?: string): string { + const normalized = error?.toLocaleLowerCase() || '' + if (normalized.includes('key') || normalized.includes('密钥')) return '数据库密钥验证失败' + if (normalized.includes('path') || normalized.includes('目录')) return '无法读取当前账号目录' + if (normalized.includes('permission') || normalized.includes('access')) + return '当前账号目录不可访问' + return '数据库连接检查未通过' +} diff --git a/src/renderer/src/features/settings/account-database/types.ts b/src/renderer/src/features/settings/account-database/types.ts new file mode 100644 index 0000000..4b482d8 --- /dev/null +++ b/src/renderer/src/features/settings/account-database/types.ts @@ -0,0 +1,18 @@ +export type DiagnosticStatus = 'idle' | 'checking' | 'success' | 'warning' | 'error' | 'unavailable' + +export type ConnectionCheckState = + | { status: 'idle'; checkedAt?: Date } + | { status: 'checking'; checkedAt?: Date } + | { status: 'success'; checkedAt: Date; identityMatched?: boolean } + | { status: 'warning'; checkedAt: Date; message?: string; identityMatched?: boolean } + | { status: 'error'; checkedAt: Date; message?: string } + +export interface ConnectionDiagnostic { + id: 'db-key' | 'contacts' | 'messages' | 'identity' | 'image-key' + label: string + status: DiagnosticStatus + result: string + detail?: string +} + +export type ConnectionOverviewStatus = 'checking' | 'success' | 'warning' | 'error' | 'unavailable' diff --git a/src/renderer/src/features/settings/account-database/useAccountDatabaseController.ts b/src/renderer/src/features/settings/account-database/useAccountDatabaseController.ts index 7be67d7..b4d14d3 100644 --- a/src/renderer/src/features/settings/account-database/useAccountDatabaseController.ts +++ b/src/renderer/src/features/settings/account-database/useAccountDatabaseController.ts @@ -1,9 +1,15 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { buildConnectionDiagnostics } from '../utils/buildConnectionDiagnostics' -import type { ConnectionStatus, SettingsSelfInfo } from '../model/types' +import type { SettingsSelfInfo } from '../model/types' +import { + buildConnectionDiagnostics, + formatConnectionCheckedAt, + getConnectionOverviewStatus, + isSameAccount, + sanitizeConnectionError +} from './diagnostics' +import type { ConnectionCheckState } from './types' interface AppSettings { - dbRoot: string imageAesKey: string } @@ -12,111 +18,118 @@ export function useAccountDatabaseController({ dbKey, dbReady, selfInfo, - onConnectionChanged, onNotice }: { dbKey: string dbReady: boolean selfInfo: SettingsSelfInfo | null - onConnectionChanged: () => void onNotice: (message: string) => void }) { const [settings, setSettings] = useState(null) - const [pendingRoot, setPendingRoot] = useState('') - const [status, setStatus] = useState(dbReady ? 'success' : 'unavailable') - const [isTesting, setIsTesting] = useState(false) - const [lastVerifiedAt, setLastVerifiedAt] = useState(null) - const [confirmDisconnect, setConfirmDisconnect] = useState(false) + const [checkState, setCheckState] = useState({ status: 'idle' }) + const [clock, setClock] = useState(() => new Date()) - const refresh = useCallback(async () => { - const result = await window.api.getSettings() - setSettings(result.settings) - setPendingRoot(result.settings.dbRoot) + useEffect(() => { + let active = true + void window.api + .getSettings() + .then((result) => { + if (active) setSettings(result.settings) + }) + .catch(() => { + if (active) setSettings(null) + }) + return () => { + active = false + } }, []) useEffect(() => { - void refresh() - }, [refresh]) - useEffect(() => setStatus(dbReady ? 'success' : 'unavailable'), [dbReady]) + if (!checkState.checkedAt) return + const timer = window.setInterval(() => setClock(new Date()), 30_000) + return () => window.clearInterval(timer) + }, [checkState.checkedAt]) + const hasImageKey = Boolean(settings?.imageAesKey.trim()) const diagnostics = useMemo( () => buildConnectionDiagnostics({ dbReady, selfInfo, hasDbKey: Boolean(dbKey.trim()), - hasImageKey: Boolean(settings?.imageAesKey.trim()) + hasImageKey, + checkState }), - [dbKey, dbReady, selfInfo, settings?.imageAesKey] + [checkState, dbKey, dbReady, hasImageKey, selfInfo] ) + const connectionStatus = useMemo( + () => getConnectionOverviewStatus({ dbReady, checkState, diagnostics }), + [checkState, dbReady, diagnostics] + ) + const lastCheckedLabel = formatConnectionCheckedAt(checkState, clock) - const chooseDirectory = async (): Promise => { - const result = await window.api.selectDbRoot() - if (!result.canceled && result.path) setPendingRoot(result.path) - } - - const applyDirectory = async (): Promise => { - if (!pendingRoot.trim()) return onNotice('请先选择数据库目录') - setStatus('checking') - const saved = await window.api.setSettings({ dbRoot: pendingRoot.trim() }) - setSettings(saved.settings) - const result = await window.api.reopenWithRoot(saved.settings.dbRoot) - if (!result.success) { - setStatus('error') - return onNotice(result.error || '重新初始化失败') - } - setStatus('success') - setLastVerifiedAt(new Date().toLocaleString('zh-CN', { hour12: false })) - onConnectionChanged() - onNotice('数据库目录已应用并重新初始化') - } - - const testConnection = async (): Promise => { - if (isTesting) return - setIsTesting(true) - setStatus('checking') + const testConnection = useCallback(async (): Promise => { + if (checkState.status === 'checking') return + setCheckState({ status: 'checking', checkedAt: checkState.checkedAt }) + const checkedAt = new Date() + setClock(checkedAt) try { - const result = await window.api.testConnection(dbKey, pendingRoot || settings?.dbRoot) + const result = await window.api.testConnection(dbKey, selfInfo?.accountRoot) if (!result.success) { - setStatus('error') - onNotice(result.error || '连接测试失败') + setCheckState({ + status: 'error', + checkedAt, + message: sanitizeConnectionError(result.error) + }) + onNotice('连接检测失败,请查看诊断结果') return } - setStatus('success') - setLastVerifiedAt(new Date().toLocaleString('zh-CN', { hour12: false })) - onNotice('连接验证通过') - } finally { - setIsTesting(false) + + const identityMatched = isSameAccount({ + currentWxid: selfInfo?.wxid, + probeWxid: result.wxid, + currentRoot: selfInfo?.accountRoot, + probeRoot: result.accountRoot + }) + if (identityMatched === false) { + setCheckState({ + status: 'warning', + checkedAt, + message: '当前连接账号与页面账号不一致', + identityMatched + }) + onNotice('连接检测失败,请查看诊断结果') + return + } + + setCheckState({ status: 'success', checkedAt, identityMatched }) + onNotice(hasImageKey ? '连接检测完成' : '连接可用,但部分能力需要配置') + } catch { + setCheckState({ status: 'error', checkedAt, message: '数据库连接检查未通过' }) + onNotice('连接检测失败,请查看诊断结果') } - } + }, [checkState, dbKey, hasImageKey, onNotice, selfInfo]) - const openAccountDirectory = async (): Promise => { + const openAccountDirectory = useCallback(async (): Promise => { const result = await window.api.openAccountRoot() - if (!result.success) onNotice(result.error || '打开账号目录失败') - } + if (!result.success) onNotice('无法打开账号目录') + }, [onNotice]) - const disconnect = async (): Promise => { - const result = await window.api.disconnectDb() - setConfirmDisconnect(false) - if (!result.success) return onNotice(result.error || '断开连接失败') - setStatus('unavailable') - onConnectionChanged() - onNotice('数据库连接已断开,微信原始数据未被修改') - } + const copyAccountDirectory = useCallback(async (): Promise => { + const accountRoot = selfInfo?.accountRoot + if (!accountRoot) return onNotice('账号目录不可用') + const result = await window.api.copyText(accountRoot) + onNotice(result.success ? '账号目录已复制' : '复制账号目录失败') + }, [onNotice, selfInfo?.accountRoot]) return { - settings, - pendingRoot, - status, diagnostics, - isTesting, - lastVerifiedAt, - confirmDisconnect, - setConfirmDisconnect, - chooseDirectory, - applyDirectory, + connectionStatus, + checkState, + isChecking: checkState.status === 'checking', + lastCheckedLabel, testConnection, openAccountDirectory, - disconnect + copyAccountDirectory } } diff --git a/src/renderer/src/features/settings/model/types.ts b/src/renderer/src/features/settings/model/types.ts index 2a309c8..fa24596 100644 --- a/src/renderer/src/features/settings/model/types.ts +++ b/src/renderer/src/features/settings/model/types.ts @@ -10,19 +10,9 @@ export type SettingsCategoryId = | 'advanced' | 'about' -export type ConnectionStatus = 'idle' | 'checking' | 'success' | 'warning' | 'error' | 'unavailable' - export interface SettingsSelfInfo { wxid: string nickname: string avatar?: string accountRoot: string } - -export interface DiagnosticItem { - id: string - label: string - status: ConnectionStatus - result: string - detail?: string -} diff --git a/src/renderer/src/features/settings/pages/AccountDatabasePage.tsx b/src/renderer/src/features/settings/pages/AccountDatabasePage.tsx index ad23a16..cb3995f 100644 --- a/src/renderer/src/features/settings/pages/AccountDatabasePage.tsx +++ b/src/renderer/src/features/settings/pages/AccountDatabasePage.tsx @@ -1,199 +1,65 @@ +import { AccountOverview } from '../account-database/AccountOverview' +import { ConnectionHealthSection } from '../account-database/ConnectionHealthSection' +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' -function ShieldIcon(): React.ReactElement { - return ( - - ) -} -function StatusIcon({ ok }: { ok: boolean }): React.ReactElement { - return {ok ? '✓' : '—'} +const STATUS_LABELS: Record = { + checking: '正在检测', + success: '连接正常', + warning: '部分能力不可用', + error: '连接异常', + unavailable: '尚未连接' } export function AccountDatabasePage({ dbKey, dbReady, selfInfo, - onConnectionChanged, onNotice }: { dbKey: string dbReady: boolean selfInfo: SettingsSelfInfo | null - onConnectionChanged: () => void onNotice: (message: string) => void }): React.ReactElement { - const controller = useAccountDatabaseController({ - dbKey, - dbReady, - selfInfo, - onConnectionChanged, - onNotice - }) - const connected = controller.status === 'success' + const controller = useAccountDatabaseController({ dbKey, dbReady, selfInfo, onNotice }) return (

账号与数据库

-

管理当前微信账号以及本地数据库连接

+

查看当前微信账号与本地数据库连接状态

- - {controller.status === 'checking' - ? '正在验证' - : connected - ? '连接正常' - : controller.status === 'error' - ? '连接异常' - : '尚未连接'} + + {STATUS_LABELS[controller.connectionStatus]}
-
- -
- 数据仅在本机读取 -

WechatExplorer 不会将您的微信聊天数据上传到云端。所有解析和存储均在本地完成。

-
-
+

账号概览

-
-
- {selfInfo?.avatar ? ( - 当前账号 - ) : ( - (selfInfo?.nickname || '?').charAt(0) - )} -
-
- 昵称 - {selfInfo?.nickname || '暂无数据'} - 最近验证 - {controller.lastVerifiedAt || '暂无数据'} -
-
- WXID - {selfInfo?.wxid || '暂无数据'} - 数据库连接状态 - - {connected ? '连接正常' : '尚未连接'} - -
-
- - -
-
-

数据库连接

-
- -
- {controller.pendingRoot || '暂无数据'} - -
-
- -
-
- 连接状态自检 - -
-
- {controller.diagnostics.map((item) => ( -
- - {item.label} - {item.result} -
- ))} -
-
-

连接管理

-
-
- 断开数据库连接 -

断开后 WechatExplorer 暂时无法读取聊天记录,但不会删除微信原始数据。

-
- -
-
- 重置连接配置 -

重置逻辑尚未提供完整的安全边界,本阶段暂不开放。

-
- -
+ void controller.testConnection()} + onOpenDirectory={() => void controller.openAccountDirectory()} + onCopyDirectory={() => void controller.copyAccountDirectory()} + /> +

连接健康检查

+
- {controller.confirmDisconnect && ( -
-
-

断开数据库连接?

-

这不会删除微信原始数据。你可以稍后重新连接。

-
- - -
-
-
- )}
) } diff --git a/src/renderer/src/features/settings/utils/buildConnectionDiagnostics.ts b/src/renderer/src/features/settings/utils/buildConnectionDiagnostics.ts deleted file mode 100644 index 1ae75b4..0000000 --- a/src/renderer/src/features/settings/utils/buildConnectionDiagnostics.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { DiagnosticItem, SettingsSelfInfo } from '../model/types' - -export function buildConnectionDiagnostics(input: { - dbReady: boolean - selfInfo: SettingsSelfInfo | null - hasDbKey: boolean - hasImageKey: boolean -}): DiagnosticItem[] { - const connected = input.dbReady - return [ - { - id: 'db-key', - label: '数据库密钥校验', - status: input.hasDbKey ? (connected ? 'success' : 'warning') : 'unavailable', - result: input.hasDbKey ? (connected ? '已用于当前连接' : '已保存,未验证') : '未检测' - }, - { - id: 'contacts', - label: '联系人索引', - status: connected ? 'success' : 'unavailable', - result: connected ? '当前连接可读取' : '未检测' - }, - { - id: 'messages', - label: '消息数据库挂载', - status: connected ? 'success' : 'unavailable', - result: connected ? '当前连接已挂载' : '未检测' - }, - { - id: 'identity', - label: '账号身份识别', - status: input.selfInfo?.wxid ? 'success' : 'unavailable', - result: input.selfInfo?.wxid ? '匹配成功' : '未检测' - }, - { - id: 'image-key', - label: '图片解密密钥', - status: input.hasImageKey ? 'success' : 'unavailable', - result: input.hasImageKey ? '已配置' : '未检测', - detail: input.hasImageKey ? undefined : '图片密钥将在后续页面管理' - } - ] -}