feat: 设置页面新UI 未做完

This commit is contained in:
电摇小子
2026-07-14 11:07:35 +08:00
committed by 电摇小子
parent 5d041aa137
commit d37341ab52
14 changed files with 650 additions and 42 deletions
+33 -1
View File
@@ -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) => {
+3
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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>
)
}
+9
View File
@@ -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 : '图片密钥将在后续页面管理'
}
]
}