精简账号与数据库设置页

This commit is contained in:
电摇小子
2026-07-14 11:07:35 +08:00
parent 842144eba0
commit ab24185670
12 changed files with 524 additions and 307 deletions
-7
View File
@@ -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)
+69 -3
View File
@@ -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); }
@@ -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}
/>
) : (
@@ -0,0 +1,106 @@
import type { SettingsSelfInfo } from '../model/types'
import type { ConnectionOverviewStatus } from './types'
function CopyIcon(): React.ReactElement {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="8" y="8" width="11" height="11" rx="2" />
<path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" />
</svg>
)
}
const CONNECTION_LABELS: Record<ConnectionOverviewStatus, string> = {
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 (
<section className="settings-card settings-account-overview">
<div className="settings-account-primary">
<div className="settings-avatar">
{selfInfo?.avatar ? (
<img src={selfInfo.avatar} alt="当前账号" referrerPolicy="no-referrer" />
) : (
(selfInfo?.nickname || '?').charAt(0)
)}
</div>
<div className="settings-account-identity">
<span></span>
<strong>{selfInfo?.nickname || '暂无数据'}</strong>
<small title={selfInfo?.wxid || undefined}>{selfInfo?.wxid || '暂无 WXID'}</small>
</div>
</div>
<dl className="settings-account-facts">
<div>
<dt></dt>
<dd className={`settings-connection-text ${connectionStatus}`}>
{CONNECTION_LABELS[connectionStatus]}
</dd>
</div>
<div>
<dt></dt>
<dd>{lastCheckedLabel}</dd>
</div>
</dl>
<div className="settings-account-actions">
<button
type="button"
className="api-primary-button"
onClick={onCheck}
disabled={isChecking}
>
{isChecking ? '正在检测...' : '重新检测'}
</button>
<button
type="button"
className="api-secondary-button"
onClick={onOpenDirectory}
disabled={!accountRoot}
>
</button>
</div>
<div className="settings-account-root">
<span></span>
<div>
<code title={accountRoot || undefined}>{accountRoot || '暂无数据'}</code>
<button
type="button"
className="settings-icon-button"
title="复制账号目录"
aria-label="复制账号目录"
onClick={onCopyDirectory}
disabled={!accountRoot}
>
<CopyIcon />
</button>
</div>
</div>
</section>
)
}
@@ -0,0 +1,37 @@
import type { ConnectionDiagnostic, DiagnosticStatus } from './types'
function DiagnosticIcon({ status }: { status: DiagnosticStatus }): React.ReactElement {
if (status === 'checking') {
return <span className="settings-diagnostic-icon checking" aria-label="检测中" />
}
const symbol =
status === 'success' ? '✓' : status === 'warning' ? '!' : status === 'error' ? '×' : '—'
return (
<span className={`settings-diagnostic-icon ${status}`} aria-hidden="true">
{symbol}
</span>
)
}
export function ConnectionHealthSection({
diagnostics,
summary
}: {
diagnostics: ConnectionDiagnostic[]
summary?: string
}): React.ReactElement {
return (
<section className="settings-card settings-health-card">
{summary && <p className="settings-diagnostic-summary">{summary}</p>}
<div className="settings-diagnostics">
{diagnostics.map((item) => (
<div className="settings-diagnostic" key={item.id}>
<DiagnosticIcon status={item.status} />
<span>{item.label}</span>
<small title={item.detail}>{item.result}</small>
</div>
))}
</div>
</section>
)
}
@@ -0,0 +1,20 @@
function ShieldIcon(): React.ReactElement {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 3.5 19 6v5.8c0 4.5-3 7.5-7 8.7-4-1.2-7-4.2-7-8.7V6l7-2.5Z" />
<path d="m9 12 2 2 4-4" />
</svg>
)
}
export function LocalPrivacyNotice(): React.ReactElement {
return (
<section className="settings-privacy-notice">
<ShieldIcon />
<div>
<strong></strong>
<p>WechatExplorer </p>
</div>
</section>
)
}
@@ -0,0 +1,154 @@
import type { SettingsSelfInfo } from '../model/types'
import type { ConnectionCheckState, ConnectionDiagnostic, ConnectionOverviewStatus } from './types'
const CORE_DIAGNOSTIC_IDS = new Set<ConnectionDiagnostic['id']>([
'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 '数据库连接检查未通过'
}
@@ -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'
@@ -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<AppSettings | null>(null)
const [pendingRoot, setPendingRoot] = useState('')
const [status, setStatus] = useState<ConnectionStatus>(dbReady ? 'success' : 'unavailable')
const [isTesting, setIsTesting] = useState(false)
const [lastVerifiedAt, setLastVerifiedAt] = useState<string | null>(null)
const [confirmDisconnect, setConfirmDisconnect] = useState(false)
const [checkState, setCheckState] = useState<ConnectionCheckState>({ 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<void> => {
const result = await window.api.selectDbRoot()
if (!result.canceled && result.path) setPendingRoot(result.path)
}
const applyDirectory = async (): Promise<void> => {
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<void> => {
if (isTesting) return
setIsTesting(true)
setStatus('checking')
const testConnection = useCallback(async (): Promise<void> => {
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<void> => {
const openAccountDirectory = useCallback(async (): Promise<void> => {
const result = await window.api.openAccountRoot()
if (!result.success) onNotice(result.error || '打开账号目录失败')
}
if (!result.success) onNotice('无法打开账号目录')
}, [onNotice])
const disconnect = async (): Promise<void> => {
const result = await window.api.disconnectDb()
setConfirmDisconnect(false)
if (!result.success) return onNotice(result.error || '断开连接失败')
setStatus('unavailable')
onConnectionChanged()
onNotice('数据库连接已断开,微信原始数据未被修改')
}
const copyAccountDirectory = useCallback(async (): Promise<void> => {
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
}
}
@@ -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
}
@@ -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 (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 3.5 19 6v5.8c0 4.5-3 7.5-7 8.7-4-1.2-7-4.2-7-8.7V6l7-2.5Z" />
<path d="m9 12 2 2 4-4" />
</svg>
)
}
function StatusIcon({ ok }: { ok: boolean }): React.ReactElement {
return <span className={`settings-diagnostic-icon ${ok ? 'ok' : ''}`}>{ok ? '✓' : '—'}</span>
const STATUS_LABELS: Record<ConnectionOverviewStatus, string> = {
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 (
<div className="settings-page">
<header className="settings-page-header">
<div>
<h1></h1>
<p></p>
<p></p>
</div>
<span className={`settings-status-badge ${controller.status}`}>
{controller.status === 'checking'
? '正在验证'
: connected
? '连接正常'
: controller.status === 'error'
? '连接异常'
: '尚未连接'}
<span className={`settings-status-badge ${controller.connectionStatus}`}>
{STATUS_LABELS[controller.connectionStatus]}
</span>
</header>
<div className="settings-page-scroll">
<div className="settings-page-content">
<section className="settings-privacy-notice">
<ShieldIcon />
<div>
<strong></strong>
<p>WechatExplorer </p>
</div>
</section>
<LocalPrivacyNotice />
<h2 className="settings-section-heading"></h2>
<section className="settings-card settings-account-overview">
<div className="settings-avatar">
{selfInfo?.avatar ? (
<img src={selfInfo.avatar} alt="当前账号" referrerPolicy="no-referrer" />
) : (
(selfInfo?.nickname || '?').charAt(0)
)}
</div>
<div className="settings-account-meta">
<span></span>
<strong>{selfInfo?.nickname || '暂无数据'}</strong>
<span></span>
<strong>{controller.lastVerifiedAt || '暂无数据'}</strong>
</div>
<div className="settings-account-meta">
<span>WXID</span>
<strong>{selfInfo?.wxid || '暂无数据'}</strong>
<span></span>
<strong className={connected ? 'success-text' : ''}>
{connected ? '连接正常' : '尚未连接'}
</strong>
</div>
<div className="settings-account-actions">
<button
type="button"
className="api-primary-button"
onClick={() => void controller.testConnection()}
disabled={controller.isTesting}
>
{controller.isTesting ? '验证中...' : '重新验证'}
</button>
<button
type="button"
className="api-secondary-button"
onClick={() => void controller.openAccountDirectory()}
>
</button>
</div>
</section>
<h2 className="settings-section-heading"></h2>
<section className="settings-card">
<label className="settings-field-label"></label>
<div className="settings-path-row">
<code title={controller.pendingRoot}>{controller.pendingRoot || '暂无数据'}</code>
<button
type="button"
className="api-secondary-button"
onClick={() => void controller.chooseDirectory()}
>
</button>
</div>
<div className="settings-apply-row">
<button
type="button"
className="api-primary-button"
onClick={() => void controller.applyDirectory()}
disabled={!controller.pendingRoot || controller.isTesting}
>
</button>
</div>
<div className="settings-diagnostic-header">
<span></span>
<button
type="button"
className="settings-text-button"
onClick={() => void controller.testConnection()}
disabled={controller.isTesting}
>
{controller.isTesting ? '正在测试...' : '重新测试连接'}
</button>
</div>
<div className="settings-diagnostics">
{controller.diagnostics.map((item) => (
<div className="settings-diagnostic" key={item.id}>
<StatusIcon ok={item.status === 'success'} />
<span>{item.label}</span>
<small title={item.detail}>{item.result}</small>
</div>
))}
</div>
</section>
<h2 className="settings-section-heading danger"></h2>
<section className="settings-card settings-danger-card">
<div>
<strong></strong>
<p> WechatExplorer </p>
</div>
<button
type="button"
className="settings-danger-button"
onClick={() => controller.setConfirmDisconnect(true)}
disabled={!dbReady}
>
</button>
<div className="settings-danger-divider" />
<div>
<strong></strong>
<p></p>
</div>
<button type="button" className="settings-danger-button" disabled>
</button>
</section>
<AccountOverview
selfInfo={selfInfo}
connectionStatus={controller.connectionStatus}
lastCheckedLabel={controller.lastCheckedLabel}
isChecking={controller.isChecking}
onCheck={() => void controller.testConnection()}
onOpenDirectory={() => void controller.openAccountDirectory()}
onCopyDirectory={() => void controller.copyAccountDirectory()}
/>
<h2 className="settings-section-heading"></h2>
<ConnectionHealthSection
diagnostics={controller.diagnostics}
summary={
controller.checkState.status === 'error' || controller.checkState.status === 'warning'
? controller.checkState.message
: undefined
}
/>
</div>
</div>
{controller.confirmDisconnect && (
<div className="settings-confirm-overlay" role="dialog" aria-modal="true">
<div className="settings-confirm">
<h2></h2>
<p></p>
<div>
<button
type="button"
className="api-secondary-button"
onClick={() => controller.setConfirmDisconnect(false)}
>
</button>
<button
type="button"
className="settings-danger-button"
onClick={() => void controller.disconnect()}
>
</button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -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 : '图片密钥将在后续页面管理'
}
]
}