mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
feat: 设置页面新UI 未做完
This commit is contained in:
+33
-1
@@ -1,5 +1,15 @@
|
||||
import './preload-env'
|
||||
import { app, shell, BrowserWindow, ipcMain, nativeImage, clipboard, Menu, Tray } from 'electron'
|
||||
import {
|
||||
app,
|
||||
shell,
|
||||
BrowserWindow,
|
||||
ipcMain,
|
||||
nativeImage,
|
||||
clipboard,
|
||||
Menu,
|
||||
Tray,
|
||||
dialog
|
||||
} from 'electron'
|
||||
import { join } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import icon from '../../resources/icon.png?asset'
|
||||
@@ -478,6 +488,28 @@ app.whenReady().then(async () => {
|
||||
return { success: true, info }
|
||||
})
|
||||
|
||||
ipcMain.handle('settings:selectDbRoot', async (event) => {
|
||||
const result = await dialog.showOpenDialog(BrowserWindow.fromWebContents(event.sender)!, {
|
||||
title: '选择微信数据库目录',
|
||||
defaultPath: loadSettings().dbRoot || undefined,
|
||||
properties: ['openDirectory']
|
||||
})
|
||||
return result.canceled ? { canceled: true } : { canceled: false, path: result.filePaths[0] }
|
||||
})
|
||||
|
||||
ipcMain.handle('settings:openAccountRoot', async () => {
|
||||
const accountRoot = chat.getCurrentAccountRoot()
|
||||
if (!accountRoot) return { success: false, error: '当前没有可打开的账号目录' }
|
||||
const error = await shell.openPath(accountRoot)
|
||||
return error ? { success: false, error } : { success: true }
|
||||
})
|
||||
|
||||
ipcMain.handle('db:disconnect', () => {
|
||||
if (!chat.isReady()) return { success: false, error: '数据库当前未连接' }
|
||||
chat.setChatDb(null)
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
ipcMain.handle('api:getStatus', () => apiServer.getState())
|
||||
|
||||
ipcMain.handle('api:start', async (_, host?: string, port?: number) => {
|
||||
|
||||
Vendored
+3
@@ -201,6 +201,9 @@ declare global {
|
||||
error?: string
|
||||
info?: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
|
||||
}>
|
||||
selectDbRoot: () => Promise<{ canceled: boolean; path?: string }>
|
||||
openAccountRoot: () => Promise<{ success: boolean; error?: string }>
|
||||
disconnectDb: () => Promise<{ success: boolean; error?: string }>
|
||||
apiStatus: () => Promise<{
|
||||
running: boolean
|
||||
host: string
|
||||
|
||||
@@ -75,6 +75,9 @@ const api = {
|
||||
testConnection: (key: string, accountRoot?: string) =>
|
||||
ipcRenderer.invoke('db:testConnection', key, accountRoot),
|
||||
reopenWithRoot: (accountRoot: string) => ipcRenderer.invoke('db:reopenWithRoot', accountRoot),
|
||||
selectDbRoot: () => ipcRenderer.invoke('settings:selectDbRoot'),
|
||||
openAccountRoot: () => ipcRenderer.invoke('settings:openAccountRoot'),
|
||||
disconnectDb: () => ipcRenderer.invoke('db:disconnect'),
|
||||
apiStatus: () => ipcRenderer.invoke('api:getStatus'),
|
||||
apiStart: (host?: string, port?: number) => ipcRenderer.invoke('api:start', host, port),
|
||||
apiStop: () => ipcRenderer.invoke('api:stop'),
|
||||
|
||||
+45
-41
@@ -1,9 +1,10 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Sidebar } from './components/Sidebar'
|
||||
import ChatWindow from './components/ChatWindow'
|
||||
import { SettingsPanel } from './components/SettingsPanel'
|
||||
import { AppShell } from './components/layout/AppShell'
|
||||
import { ApiWorkspace } from './features/api-center/ApiWorkspace'
|
||||
import { SettingsWorkspace } from './features/settings/SettingsWorkspace'
|
||||
import type { SettingsCategoryId } from './features/settings/model/types'
|
||||
import { AppPage } from './components/layout/navigation'
|
||||
import { AiReportWorkspace } from './components/reports/AiReportWorkspace'
|
||||
import { ReportHistorySidebar } from './components/reports/ReportHistorySidebar'
|
||||
@@ -149,6 +150,7 @@ const sortMessagesChronologically = (items: Message[]): Message[] =>
|
||||
|
||||
function App(): React.ReactElement {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [isDatabaseConnected, setIsDatabaseConnected] = useState(false)
|
||||
const [dbKey, setDbKey] = useState(import.meta.env.VITE_DB_KEY || '')
|
||||
const [contacts, setContacts] = useState<Contact[]>([])
|
||||
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
|
||||
@@ -162,8 +164,8 @@ function App(): React.ReactElement {
|
||||
const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal')
|
||||
const [showDbKey, setShowDbKey] = useState(false)
|
||||
const [showMacKeyFaq, setShowMacKeyFaq] = useState(false)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [activePage, setActivePage] = useState<AppPage>('archive')
|
||||
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
|
||||
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
|
||||
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result')
|
||||
const [generatedReports, setGeneratedReports] = useState<GeneratedReportRecord[]>([])
|
||||
@@ -173,7 +175,7 @@ function App(): React.ReactElement {
|
||||
const [reportNotice, setReportNotice] = useState('')
|
||||
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
|
||||
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
|
||||
const [aiModelConfig, setAiModelConfig] = useState<AiModelConfig>(() => ({
|
||||
const [aiModelConfig] = useState<AiModelConfig>(() => ({
|
||||
apiKey: localStorage.getItem('ai_api_key') || '',
|
||||
baseURL: localStorage.getItem('ai_base_url') || 'https://api.deepseek.com',
|
||||
model: localStorage.getItem('ai_model') || 'deepseek-chat'
|
||||
@@ -367,6 +369,7 @@ function App(): React.ReactElement {
|
||||
if (success) {
|
||||
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
||||
setIsAuthenticated(true)
|
||||
setIsDatabaseConnected(true)
|
||||
setDbKeyStatus('已自动连接')
|
||||
setDbKeyStatusKind('success')
|
||||
await loadContacts()
|
||||
@@ -448,6 +451,7 @@ function App(): React.ReactElement {
|
||||
percent: 100
|
||||
})
|
||||
setIsAuthenticated(true)
|
||||
setIsDatabaseConnected(true)
|
||||
setBootState('login')
|
||||
window.setTimeout(() => {
|
||||
setStartupProgress(null)
|
||||
@@ -791,6 +795,7 @@ function App(): React.ReactElement {
|
||||
|
||||
const handlePageChange = (page: AppPage): void => {
|
||||
setActivePage(page)
|
||||
if (page === 'settings') setSettingsCategory('account-database')
|
||||
if (page === 'report' && isGroupContact(selectedContact) && !reportSourceContact) {
|
||||
setReportSourceContact(selectedContact)
|
||||
}
|
||||
@@ -800,6 +805,17 @@ function App(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
const openSettings = (): void => {
|
||||
setSettingsCategory('account-database')
|
||||
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')
|
||||
@@ -830,12 +846,6 @@ function App(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveAiModelConfig = (): void => {
|
||||
localStorage.setItem('ai_api_key', aiModelConfig.apiKey)
|
||||
localStorage.setItem('ai_base_url', aiModelConfig.baseURL)
|
||||
localStorage.setItem('ai_model', aiModelConfig.model)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
reportGeneration.phase !== 'success' ||
|
||||
@@ -989,8 +999,8 @@ function App(): React.ReactElement {
|
||||
dateRange={dateRange}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
selfInfo={selfInfo}
|
||||
dbReady={isAuthenticated}
|
||||
onOpenSettings={() => setShowSettings(true)}
|
||||
dbReady={isDatabaseConnected}
|
||||
onOpenSettings={openSettings}
|
||||
/>
|
||||
<div className="resizer" onMouseDown={startResizing} />
|
||||
<ChatWindow
|
||||
@@ -1016,11 +1026,11 @@ function App(): React.ReactElement {
|
||||
reports={generatedReports}
|
||||
selectedReportId={selectedReportId}
|
||||
selfInfo={selfInfo}
|
||||
dbReady={isAuthenticated}
|
||||
dbReady={isDatabaseConnected}
|
||||
onSelectReport={openReport}
|
||||
onCreateReport={openReportConfigure}
|
||||
onDeleteReport={handleDeleteReport}
|
||||
onOpenSettings={() => setShowSettings(true)}
|
||||
onOpenSettings={openSettings}
|
||||
/>
|
||||
<ReportViewer
|
||||
report={selectedReport}
|
||||
@@ -1038,9 +1048,9 @@ function App(): React.ReactElement {
|
||||
contacts={contacts}
|
||||
selectedContact={reportSourceContact}
|
||||
selfInfo={selfInfo}
|
||||
dbReady={isAuthenticated}
|
||||
dbReady={isDatabaseConnected}
|
||||
onSelectContact={handleSelectReportSource}
|
||||
onOpenSettings={() => setShowSettings(true)}
|
||||
onOpenSettings={openSettings}
|
||||
/>
|
||||
<AiReportWorkspace
|
||||
sourceContact={reportSourceContact}
|
||||
@@ -1058,7 +1068,7 @@ function App(): React.ReactElement {
|
||||
isGenerating={reportGeneration.isGenerating}
|
||||
onSummaryDateRangeChange={setSummaryDateRange}
|
||||
onSummaryMessageTypesChange={setSummaryMessageTypes}
|
||||
onOpenModelSettings={() => setShowSettings(true)}
|
||||
onOpenModelSettings={openSettings}
|
||||
onGenerate={() => {
|
||||
reportGeneration.resetGenerationStatus()
|
||||
void reportGeneration.generate()
|
||||
@@ -1090,16 +1100,28 @@ function App(): React.ReactElement {
|
||||
return (
|
||||
<ApiWorkspace
|
||||
selectedContact={selectedContact}
|
||||
dbReady={isAuthenticated}
|
||||
onOpenSettings={() => {
|
||||
setActivePage('settings')
|
||||
setShowSettings(true)
|
||||
dbReady={isDatabaseConnected}
|
||||
onOpenSettings={openSettings}
|
||||
/>
|
||||
)
|
||||
case 'settings':
|
||||
return (
|
||||
<SettingsWorkspace
|
||||
selectedCategory={settingsCategory}
|
||||
onCategoryChange={setSettingsCategory}
|
||||
selfInfo={selfInfo}
|
||||
dbReady={isDatabaseConnected}
|
||||
dbKey={dbKey}
|
||||
onConnectionChanged={handleSettingsConnectionChanged}
|
||||
onNotice={(message) => {
|
||||
setReportNotice(message)
|
||||
window.setTimeout(() => setReportNotice(''), 3200)
|
||||
}}
|
||||
onOpenSettings={openSettings}
|
||||
/>
|
||||
)
|
||||
case 'search':
|
||||
case 'export':
|
||||
case 'settings':
|
||||
return renderPlaceholderPage(activePage)
|
||||
}
|
||||
}
|
||||
@@ -1234,30 +1256,12 @@ function App(): React.ReactElement {
|
||||
<AppShell
|
||||
activePage={activePage}
|
||||
selfInfo={selfInfo}
|
||||
dbReady={isAuthenticated}
|
||||
dbReady={isDatabaseConnected}
|
||||
onPageChange={handlePageChange}
|
||||
onOpenSettings={() => {
|
||||
setActivePage('settings')
|
||||
setShowSettings(true)
|
||||
}}
|
||||
onOpenSettings={openSettings}
|
||||
>
|
||||
{reportNotice && <div className="app-toast">{reportNotice}</div>}
|
||||
{renderCurrentWorkspace()}
|
||||
<SettingsPanel
|
||||
open={showSettings}
|
||||
selfInfo={selfInfo}
|
||||
dbReady={isAuthenticated}
|
||||
dbKey={dbKey}
|
||||
aiModelConfig={aiModelConfig}
|
||||
onClose={() => setShowSettings(false)}
|
||||
onDbKeyChange={setDbKey}
|
||||
onAiModelConfigChange={setAiModelConfig}
|
||||
onSaveAiModelConfig={handleSaveAiModelConfig}
|
||||
onDbRootChanged={() => {
|
||||
void refreshSelfInfo()
|
||||
void loadContacts()
|
||||
}}
|
||||
/>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3548,6 +3548,15 @@ body {
|
||||
|
||||
/* API-01 本地 API 中心 */
|
||||
.api-center-layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; background: var(--wxex-bg-main); }
|
||||
|
||||
/* SETTINGS-01: formal settings workspace. The legacy SettingsPanel remains only as a fallback source. */
|
||||
.settings-workspace { display:flex; width:100%; height:100%; min-width:0; min-height:0; overflow:hidden; background:#fafbfa; }
|
||||
.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); } }
|
||||
.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); }
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Legacy fallback: SETTINGS-01 moved the default entry to features/settings.
|
||||
// Keep this panel intact until its database-key, image-key, AI and API sections are migrated.
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
interface SelfInfo {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { SettingsEmptyState } from './components/SettingsEmptyState'
|
||||
import { SettingsSidebar } from './components/SettingsSidebar'
|
||||
import { SETTINGS_CATEGORY_LABELS } from './model/settingsNavigation'
|
||||
import type { SettingsCategoryId, SettingsSelfInfo } from './model/types'
|
||||
import { AccountDatabasePage } from './pages/AccountDatabasePage'
|
||||
|
||||
export function SettingsWorkspace({
|
||||
selectedCategory,
|
||||
onCategoryChange,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
dbKey,
|
||||
onConnectionChanged,
|
||||
onNotice,
|
||||
onOpenSettings
|
||||
}: {
|
||||
selectedCategory: SettingsCategoryId
|
||||
onCategoryChange: (id: SettingsCategoryId) => void
|
||||
selfInfo: SettingsSelfInfo | null
|
||||
dbReady: boolean
|
||||
dbKey: string
|
||||
onConnectionChanged: () => void
|
||||
onNotice: (message: string) => void
|
||||
onOpenSettings: () => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="settings-workspace">
|
||||
<SettingsSidebar
|
||||
selectedId={selectedCategory}
|
||||
onSelect={onCategoryChange}
|
||||
selfInfo={selfInfo}
|
||||
dbReady={dbReady}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
{selectedCategory === 'account-database' ? (
|
||||
<AccountDatabasePage
|
||||
dbKey={dbKey}
|
||||
dbReady={dbReady}
|
||||
selfInfo={selfInfo}
|
||||
onConnectionChanged={onConnectionChanged}
|
||||
onNotice={onNotice}
|
||||
/>
|
||||
) : (
|
||||
<SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { buildConnectionDiagnostics } from '../utils/buildConnectionDiagnostics'
|
||||
import type { ConnectionStatus, SettingsSelfInfo } from '../model/types'
|
||||
|
||||
interface AppSettings {
|
||||
dbRoot: string
|
||||
imageAesKey: string
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
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 refresh = useCallback(async () => {
|
||||
const result = await window.api.getSettings()
|
||||
setSettings(result.settings)
|
||||
setPendingRoot(result.settings.dbRoot)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
useEffect(() => setStatus(dbReady ? 'success' : 'unavailable'), [dbReady])
|
||||
|
||||
const diagnostics = useMemo(
|
||||
() =>
|
||||
buildConnectionDiagnostics({
|
||||
dbReady,
|
||||
selfInfo,
|
||||
hasDbKey: Boolean(dbKey.trim()),
|
||||
hasImageKey: Boolean(settings?.imageAesKey.trim())
|
||||
}),
|
||||
[dbKey, dbReady, selfInfo, settings?.imageAesKey]
|
||||
)
|
||||
|
||||
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')
|
||||
try {
|
||||
const result = await window.api.testConnection(dbKey, pendingRoot || settings?.dbRoot)
|
||||
if (!result.success) {
|
||||
setStatus('error')
|
||||
onNotice(result.error || '连接测试失败')
|
||||
return
|
||||
}
|
||||
setStatus('success')
|
||||
setLastVerifiedAt(new Date().toLocaleString('zh-CN', { hour12: false }))
|
||||
onNotice('连接验证通过')
|
||||
} finally {
|
||||
setIsTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openAccountDirectory = async (): Promise<void> => {
|
||||
const result = await window.api.openAccountRoot()
|
||||
if (!result.success) onNotice(result.error || '打开账号目录失败')
|
||||
}
|
||||
|
||||
const disconnect = async (): Promise<void> => {
|
||||
const result = await window.api.disconnectDb()
|
||||
setConfirmDisconnect(false)
|
||||
if (!result.success) return onNotice(result.error || '断开连接失败')
|
||||
setStatus('unavailable')
|
||||
onConnectionChanged()
|
||||
onNotice('数据库连接已断开,微信原始数据未被修改')
|
||||
}
|
||||
|
||||
return {
|
||||
settings,
|
||||
pendingRoot,
|
||||
status,
|
||||
diagnostics,
|
||||
isTesting,
|
||||
lastVerifiedAt,
|
||||
confirmDisconnect,
|
||||
setConfirmDisconnect,
|
||||
chooseDirectory,
|
||||
applyDirectory,
|
||||
testConnection,
|
||||
openAccountDirectory,
|
||||
disconnect
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function SettingsEmptyState({ label }: { label: string }): React.ReactElement {
|
||||
return (
|
||||
<div className="settings-empty-state">
|
||||
<h2>{label}</h2>
|
||||
<p>该设置将在后续阶段接入。</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { AccountSummary } from '../../../components/account/AccountSummary'
|
||||
import { SETTINGS_NAVIGATION } from '../model/settingsNavigation'
|
||||
import type { SettingsCategoryId, SettingsSelfInfo } from '../model/types'
|
||||
|
||||
export function SettingsSidebar({
|
||||
selectedId,
|
||||
onSelect,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
onOpenSettings
|
||||
}: {
|
||||
selectedId: SettingsCategoryId
|
||||
onSelect: (id: SettingsCategoryId) => void
|
||||
selfInfo: SettingsSelfInfo | null
|
||||
dbReady: boolean
|
||||
onOpenSettings: () => void
|
||||
}): React.ReactElement {
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
SETTINGS_NAVIGATION.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => item.label.includes(keyword.trim()))
|
||||
})).filter((group) => group.items.length),
|
||||
[keyword]
|
||||
)
|
||||
return (
|
||||
<aside className="settings-sidebar">
|
||||
<header>
|
||||
<h1>设置</h1>
|
||||
<p>管理账号、数据连接和本地能力</p>
|
||||
<label className="settings-search">
|
||||
<span>⌕</span>
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索设置"
|
||||
/>
|
||||
</label>
|
||||
</header>
|
||||
<div className="settings-sidebar-list">
|
||||
{groups.map((group) => (
|
||||
<section key={group.label}>
|
||||
<h2>{group.label}</h2>
|
||||
{group.items.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.id}
|
||||
className={selectedId === item.id ? 'active' : ''}
|
||||
onClick={() => onSelect(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<div className="settings-sidebar-account">
|
||||
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} />
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { SettingsCategoryId } from './types'
|
||||
|
||||
export interface SettingsNavigationGroup {
|
||||
label: string
|
||||
items: { id: SettingsCategoryId; label: string }[]
|
||||
}
|
||||
|
||||
export const SETTINGS_NAVIGATION: SettingsNavigationGroup[] = [
|
||||
{
|
||||
label: '连接',
|
||||
items: [
|
||||
{ id: 'account-database', label: '账号与数据库' },
|
||||
{ id: 'database-key', label: '数据库密钥' },
|
||||
{ id: 'image-key', label: '图片解密' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '智能能力',
|
||||
items: [
|
||||
{ id: 'ai-model', label: 'AI 模型' },
|
||||
{ id: 'local-api', label: '本地 API' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '数据管理',
|
||||
items: [
|
||||
{ id: 'storage-export', label: '存储与导出' },
|
||||
{ id: 'cache-cleanup', label: '缓存与清理' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '应用',
|
||||
items: [
|
||||
{ id: 'appearance', label: '外观与行为' },
|
||||
{ id: 'advanced', label: '高级' },
|
||||
{ id: 'about', label: '关于' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export const SETTINGS_CATEGORY_LABELS = Object.fromEntries(
|
||||
SETTINGS_NAVIGATION.flatMap((group) => group.items.map((item) => [item.id, item.label]))
|
||||
) as Record<SettingsCategoryId, string>
|
||||
@@ -0,0 +1,28 @@
|
||||
export type SettingsCategoryId =
|
||||
| 'account-database'
|
||||
| 'database-key'
|
||||
| 'image-key'
|
||||
| 'ai-model'
|
||||
| 'local-api'
|
||||
| 'storage-export'
|
||||
| 'cache-cleanup'
|
||||
| 'appearance'
|
||||
| '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
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useAccountDatabaseController } from '../account-database/useAccountDatabaseController'
|
||||
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>
|
||||
}
|
||||
|
||||
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'
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<header className="settings-page-header">
|
||||
<div>
|
||||
<h1>账号与数据库</h1>
|
||||
<p>管理当前微信账号以及本地数据库连接</p>
|
||||
</div>
|
||||
<span className={`settings-status-badge ${controller.status}`}>
|
||||
{controller.status === 'checking'
|
||||
? '正在验证'
|
||||
: connected
|
||||
? '连接正常'
|
||||
: controller.status === 'error'
|
||||
? '连接异常'
|
||||
: '尚未连接'}
|
||||
</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>
|
||||
<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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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 : '图片密钥将在后续页面管理'
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user