diff --git a/src/main/index.ts b/src/main/index.ts index 7376ef4..ca3e88e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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) => { diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 7eda0a8..40f17dc 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -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 diff --git a/src/preload/index.ts b/src/preload/index.ts index 72bb2b4..852e057 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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'), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b017d0a..bbdd972 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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([]) const [selectedContact, setSelectedContact] = useState(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('archive') + const [settingsCategory, setSettingsCategory] = useState('account-database') const [reportSourceContact, setReportSourceContact] = useState(null) const [reportWorkspaceView, setReportWorkspaceView] = useState('result') const [generatedReports, setGeneratedReports] = useState([]) @@ -173,7 +175,7 @@ function App(): React.ReactElement { const [reportNotice, setReportNotice] = useState('') const [summaryDateRange, setSummaryDateRange] = useState('today') const [summaryMessageTypes, setSummaryMessageTypes] = useState(['text']) - const [aiModelConfig, setAiModelConfig] = useState(() => ({ + const [aiModelConfig] = useState(() => ({ 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} />
setShowSettings(true)} + onOpenSettings={openSettings} /> setShowSettings(true)} + onOpenSettings={openSettings} /> setShowSettings(true)} + onOpenModelSettings={openSettings} onGenerate={() => { reportGeneration.resetGenerationStatus() void reportGeneration.generate() @@ -1090,16 +1100,28 @@ function App(): React.ReactElement { return ( { - setActivePage('settings') - setShowSettings(true) + dbReady={isDatabaseConnected} + onOpenSettings={openSettings} + /> + ) + case 'settings': + return ( + { + 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 { { - setActivePage('settings') - setShowSettings(true) - }} + onOpenSettings={openSettings} > {reportNotice &&
{reportNotice}
} {renderCurrentWorkspace()} - setShowSettings(false)} - onDbKeyChange={setDbKey} - onAiModelConfigChange={setAiModelConfig} - onSaveAiModelConfig={handleSaveAiModelConfig} - onDbRootChanged={() => { - void refreshSelfInfo() - void loadContacts() - }} - />
) } diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 4fe158c..1ec418c 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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); } diff --git a/src/renderer/src/components/SettingsPanel.tsx b/src/renderer/src/components/SettingsPanel.tsx index 06ebb6f..c33f947 100644 --- a/src/renderer/src/components/SettingsPanel.tsx +++ b/src/renderer/src/components/SettingsPanel.tsx @@ -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 { diff --git a/src/renderer/src/features/settings/SettingsWorkspace.tsx b/src/renderer/src/features/settings/SettingsWorkspace.tsx new file mode 100644 index 0000000..efe3066 --- /dev/null +++ b/src/renderer/src/features/settings/SettingsWorkspace.tsx @@ -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 ( +
+ + {selectedCategory === 'account-database' ? ( + + ) : ( + + )} +
+ ) +} diff --git a/src/renderer/src/features/settings/account-database/useAccountDatabaseController.ts b/src/renderer/src/features/settings/account-database/useAccountDatabaseController.ts new file mode 100644 index 0000000..7be67d7 --- /dev/null +++ b/src/renderer/src/features/settings/account-database/useAccountDatabaseController.ts @@ -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(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 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 => { + 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') + 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 => { + const result = await window.api.openAccountRoot() + if (!result.success) onNotice(result.error || '打开账号目录失败') + } + + const disconnect = async (): Promise => { + 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 + } +} diff --git a/src/renderer/src/features/settings/components/SettingsEmptyState.tsx b/src/renderer/src/features/settings/components/SettingsEmptyState.tsx new file mode 100644 index 0000000..10dafbb --- /dev/null +++ b/src/renderer/src/features/settings/components/SettingsEmptyState.tsx @@ -0,0 +1,8 @@ +export function SettingsEmptyState({ label }: { label: string }): React.ReactElement { + return ( +
+

{label}

+

该设置将在后续阶段接入。

+
+ ) +} diff --git a/src/renderer/src/features/settings/components/SettingsSidebar.tsx b/src/renderer/src/features/settings/components/SettingsSidebar.tsx new file mode 100644 index 0000000..7c90c99 --- /dev/null +++ b/src/renderer/src/features/settings/components/SettingsSidebar.tsx @@ -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 ( + + ) +} diff --git a/src/renderer/src/features/settings/model/settingsNavigation.ts b/src/renderer/src/features/settings/model/settingsNavigation.ts new file mode 100644 index 0000000..0fa8a7b --- /dev/null +++ b/src/renderer/src/features/settings/model/settingsNavigation.ts @@ -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 diff --git a/src/renderer/src/features/settings/model/types.ts b/src/renderer/src/features/settings/model/types.ts new file mode 100644 index 0000000..2a309c8 --- /dev/null +++ b/src/renderer/src/features/settings/model/types.ts @@ -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 +} diff --git a/src/renderer/src/features/settings/pages/AccountDatabasePage.tsx b/src/renderer/src/features/settings/pages/AccountDatabasePage.tsx new file mode 100644 index 0000000..ad23a16 --- /dev/null +++ b/src/renderer/src/features/settings/pages/AccountDatabasePage.tsx @@ -0,0 +1,199 @@ +import { useAccountDatabaseController } from '../account-database/useAccountDatabaseController' +import type { SettingsSelfInfo } from '../model/types' + +function ShieldIcon(): React.ReactElement { + return ( + + ) +} +function StatusIcon({ ok }: { ok: boolean }): React.ReactElement { + return {ok ? '✓' : '—'} +} + +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 ( +
+
+
+

账号与数据库

+

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

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

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 暂时无法读取聊天记录,但不会删除微信原始数据。

+
+ +
+
+ 重置连接配置 +

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

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

断开数据库连接?

+

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

+
+ + +
+
+
+ )} +
+ ) +} diff --git a/src/renderer/src/features/settings/utils/buildConnectionDiagnostics.ts b/src/renderer/src/features/settings/utils/buildConnectionDiagnostics.ts new file mode 100644 index 0000000..1ae75b4 --- /dev/null +++ b/src/renderer/src/features/settings/utils/buildConnectionDiagnostics.ts @@ -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 : '图片密钥将在后续页面管理' + } + ] +}