feat: show settings as modal dialog

This commit is contained in:
aits2026
2026-03-10 13:32:19 +08:00
parent 5b2e48badd
commit 37796c98c9
4 changed files with 248 additions and 77 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom' import { Routes, Route, Navigate, useNavigate, useLocation, type Location } from 'react-router-dom'
import TitleBar from './components/TitleBar' import TitleBar from './components/TitleBar'
import Sidebar from './components/Sidebar' import Sidebar from './components/Sidebar'
import RouteGuard from './components/RouteGuard' import RouteGuard from './components/RouteGuard'
@@ -47,6 +47,13 @@ function RouteStateRedirect({ to }: { to: string }) {
function App() { function App() {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const settingsBackgroundRef = useRef<Location>({
pathname: '/home',
search: '',
hash: '',
state: null,
key: 'settings-fallback'
} as Location)
const { const {
setDbConnected, setDbConnected,
@@ -70,7 +77,12 @@ function App() {
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') const isChatHistoryWindow = location.pathname.startsWith('/chat-history/')
const isStandaloneChatWindow = location.pathname === '/chat-window' const isStandaloneChatWindow = location.pathname === '/chat-window'
const isNotificationWindow = location.pathname === '/notification-window' const isNotificationWindow = location.pathname === '/notification-window'
const isExportRoute = location.pathname === '/export' const isSettingsRoute = location.pathname === '/settings'
const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null
const routeLocation = isSettingsRoute
? settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
: location
const isExportRoute = routeLocation.pathname === '/export'
const [themeHydrated, setThemeHydrated] = useState(false) const [themeHydrated, setThemeHydrated] = useState(false)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
@@ -89,6 +101,12 @@ function App() {
// 数据收集同意状态 // 数据收集同意状态
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false) const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
useEffect(() => {
if (location.pathname !== '/settings') {
settingsBackgroundRef.current = location
}
}, [location])
useEffect(() => { useEffect(() => {
const root = document.documentElement const root = document.documentElement
const body = document.body const body = document.body
@@ -437,6 +455,25 @@ function App() {
} }
// 主窗口 - 完整布局 // 主窗口 - 完整布局
const handleCloseSettings = () => {
const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
if (backgroundLocation.pathname === '/settings') {
navigate('/home', { replace: true })
return
}
navigate(
{
pathname: backgroundLocation.pathname,
search: backgroundLocation.search,
hash: backgroundLocation.hash
},
{
replace: true,
state: backgroundLocation.state
}
)
}
return ( return (
<div className="app-container"> <div className="app-container">
<div className="window-drag-region" aria-hidden="true" /> <div className="window-drag-region" aria-hidden="true" />
@@ -568,7 +605,7 @@ function App() {
<ExportPage /> <ExportPage />
</div> </div>
<Routes> <Routes location={routeLocation}>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/home" element={<HomePage />} /> <Route path="/home" element={<HomePage />} />
<Route path="/chat" element={<ChatPage />} /> <Route path="/chat" element={<ChatPage />} />
@@ -584,7 +621,6 @@ function App() {
<Route path="/dual-report" element={<DualReportPage />} /> <Route path="/dual-report" element={<DualReportPage />} />
<Route path="/dual-report/view" element={<DualReportWindow />} /> <Route path="/dual-report/view" element={<DualReportWindow />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} /> <Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
<Route path="/sns" element={<SnsPage />} /> <Route path="/sns" element={<SnsPage />} />
<Route path="/contacts" element={<ContactsPage />} /> <Route path="/contacts" element={<ContactsPage />} />
@@ -593,6 +629,10 @@ function App() {
</RouteGuard> </RouteGuard>
</main> </main>
</div> </div>
{isSettingsRoute && (
<SettingsPage onClose={handleCloseSettings} />
)}
</div> </div>
) )
} }

View File

@@ -284,7 +284,11 @@ function Sidebar({ collapsed }: SidebarProps) {
const openSettingsFromAccountMenu = () => { const openSettingsFromAccountMenu = () => {
setIsAccountMenuOpen(false) setIsAccountMenuOpen(false)
navigate('/settings') navigate('/settings', {
state: {
backgroundLocation: location
}
})
} }
const handleConfirmClearAccountData = async () => { const handleConfirmClearAccountData = async () => {
@@ -432,7 +436,12 @@ function Sidebar({ collapsed }: SidebarProps) {
setLocked(true) setLocked(true)
return return
} }
navigate('/settings', { state: { initialTab: 'security' } }) navigate('/settings', {
state: {
initialTab: 'security',
backgroundLocation: location
}
})
}} }}
title={collapsed ? (authEnabled ? '锁定' : '未锁定') : undefined} title={collapsed ? (authEnabled ? '锁定' : '未锁定') : undefined}
> >

View File

@@ -1,17 +1,38 @@
.settings-modal-overlay {
position: fixed;
top: 41px;
left: 0;
right: 0;
bottom: 0;
z-index: 2050;
display: flex;
align-items: center;
justify-content: center;
padding: 28px 32px;
background: rgba(15, 23, 42, 0.28);
backdrop-filter: blur(10px);
}
.settings-page { .settings-page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; width: min(1160px, calc(100vw - 96px));
margin: -24px; height: min(820px, calc(100vh - 120px));
max-height: 100%;
padding: 24px; padding: 24px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 24px;
box-shadow: 0 28px 80px rgba(15, 23, 42, 0.22);
overflow: hidden; overflow: hidden;
} }
.settings-header { .settings-header {
display: flex; display: flex;
align-items: center; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
margin-bottom: 20px; gap: 20px;
margin-bottom: 18px;
flex-shrink: 0; flex-shrink: 0;
h1 { h1 {
@@ -22,29 +43,76 @@
} }
} }
.settings-title-block {
display: flex;
flex-direction: column;
gap: 6px;
p {
margin: 0;
font-size: 13px;
color: var(--text-tertiary);
line-height: 1.6;
}
}
.settings-actions { .settings-actions {
display: flex; display: flex;
align-items: center;
gap: 12px; gap: 12px;
} }
.settings-close-btn {
width: 36px;
height: 36px;
padding: 0;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-secondary);
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
border-color: rgba(139, 115, 85, 0.28);
}
}
.settings-layout {
flex: 1;
min-height: 0;
display: flex;
gap: 20px;
overflow: hidden;
}
.settings-tabs { .settings-tabs {
display: flex; display: flex;
gap: 4px; flex-direction: column;
padding: 4px; gap: 6px;
background: var(--bg-tertiary); padding: 12px;
border-radius: 12px; width: 220px;
margin-bottom: 20px;
flex-shrink: 0; flex-shrink: 0;
width: fit-content; background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 20px;
overflow-y: auto;
} }
.tab-btn { .tab-btn {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 10px 18px; width: 100%;
justify-content: flex-start;
padding: 11px 14px;
border: none; border: none;
border-radius: 8px; border-radius: 12px;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
@@ -67,6 +135,7 @@
.settings-body { .settings-body {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
min-width: 0;
padding-right: 8px; padding-right: 8px;
&::-webkit-scrollbar { &::-webkit-scrollbar {
@@ -85,8 +154,10 @@
.tab-content { .tab-content {
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 16px; border: 1px solid var(--border-color);
border-radius: 20px;
padding: 24px; padding: 24px;
min-height: 100%;
.section-desc { .section-desc {
font-size: 13px; font-size: 13px;
@@ -932,7 +1003,7 @@
padding: 10px 24px; padding: 10px 24px;
border-radius: 9999px; border-radius: 9999px;
font-size: 14px; font-size: 14px;
z-index: 100; z-index: 2200;
animation: slideDown 0.3s ease; animation: slideDown 0.3s ease;
&.success { &.success {
@@ -946,6 +1017,27 @@
} }
} }
@media (max-width: 960px) {
.settings-modal-overlay {
padding: 20px;
}
.settings-page {
width: min(100%, calc(100vw - 40px));
height: min(100%, calc(100vh - 82px));
padding: 20px;
}
.settings-layout {
flex-direction: column;
}
.settings-tabs {
width: 100%;
max-height: 220px;
}
}
@keyframes slideDown { @keyframes slideDown {
from { from {
opacity: 0; opacity: 0;

View File

@@ -10,7 +10,7 @@ import {
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor, RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
Palette, Database, HardDrive, Info, RefreshCw, ChevronDown, Download, Mic, Palette, Database, HardDrive, Info, RefreshCw, ChevronDown, Download, Mic,
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2 ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2, X
} from 'lucide-react' } from 'lucide-react'
import { Avatar } from '../components/Avatar' import { Avatar } from '../components/Avatar'
import './SettingsPage.scss' import './SettingsPage.scss'
@@ -36,7 +36,11 @@ interface WxidOption {
modifiedTime: number modifiedTime: number
} }
function SettingsPage() { interface SettingsPageProps {
onClose?: () => void
}
function SettingsPage({ onClose }: SettingsPageProps = {}) {
const location = useLocation() const location = useLocation()
const { const {
isDbConnected, isDbConnected,
@@ -195,6 +199,17 @@ function SettingsPage() {
setActiveTab(initialTab) setActiveTab(initialTab)
}, [location.state]) }, [location.state])
useEffect(() => {
if (!onClose) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onClose])
useEffect(() => { useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
setDbKeyStatus(payload.message) setDbKeyStatus(payload.message)
@@ -2049,66 +2064,81 @@ function SettingsPage() {
) )
return ( return (
<div className="settings-page"> <div className="settings-modal-overlay" onClick={() => onClose?.()}>
{message && <div className={`message-toast ${message.success ? 'success' : 'error'}`}>{message.text}</div>} <div className="settings-page" onClick={(event) => event.stopPropagation()}>
{message && <div className={`message-toast ${message.success ? 'success' : 'error'}`}>{message.text}</div>}
{/* 多账号选择对话框 */} {/* 多账号选择对话框 */}
{showWxidSelect && wxidOptions.length > 1 && ( {showWxidSelect && wxidOptions.length > 1 && (
<div className="wxid-dialog-overlay" onClick={() => setShowWxidSelect(false)}> <div className="wxid-dialog-overlay" onClick={() => setShowWxidSelect(false)}>
<div className="wxid-dialog" onClick={(e) => e.stopPropagation()}> <div className="wxid-dialog" onClick={(e) => e.stopPropagation()}>
<div className="wxid-dialog-header"> <div className="wxid-dialog-header">
<h3></h3> <h3></h3>
<p>使</p> <p>使</p>
</div> </div>
<div className="wxid-dialog-list"> <div className="wxid-dialog-list">
{wxidOptions.map((opt) => ( {wxidOptions.map((opt) => (
<div <div
key={opt.wxid} key={opt.wxid}
className={`wxid-dialog-item ${opt.wxid === wxid ? 'active' : ''}`} className={`wxid-dialog-item ${opt.wxid === wxid ? 'active' : ''}`}
onClick={() => handleSelectWxid(opt.wxid)} onClick={() => handleSelectWxid(opt.wxid)}
> >
<span className="wxid-id">{opt.wxid}</span> <span className="wxid-id">{opt.wxid}</span>
<span className="wxid-date"> {new Date(opt.modifiedTime).toLocaleString()}</span> <span className="wxid-date"> {new Date(opt.modifiedTime).toLocaleString()}</span>
</div> </div>
))} ))}
</div> </div>
<div className="wxid-dialog-footer"> <div className="wxid-dialog-footer">
<button className="btn btn-secondary" onClick={() => setShowWxidSelect(false)}></button> <button className="btn btn-secondary" onClick={() => setShowWxidSelect(false)}></button>
</div>
</div> </div>
</div> </div>
</div> )}
)}
<div className="settings-header"> <div className="settings-header">
<h1></h1> <div className="settings-title-block">
<div className="settings-actions"> <h1></h1>
<button className="btn btn-secondary" onClick={handleTestConnection} disabled={isLoading || isTesting}> <p> WeFlow </p>
<Plug size={16} /> {isTesting ? '测试中...' : '测试连接'} </div>
</button> <div className="settings-actions">
<button className="btn btn-secondary" onClick={handleTestConnection} disabled={isLoading || isTesting}>
<Plug size={16} /> {isTesting ? '测试中...' : '测试连接'}
</button>
{onClose && (
<button type="button" className="settings-close-btn" onClick={onClose} aria-label="关闭设置">
<X size={18} />
</button>
)}
</div>
</div>
<div className="settings-layout">
<div className="settings-tabs" role="tablist" aria-label="设置项">
{tabs.map(tab => (
<button
key={tab.id}
className={`tab-btn ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
<tab.icon size={16} />
<span>{tab.label}</span>
</button>
))}
</div>
<div className="settings-body">
{activeTab === 'appearance' && renderAppearanceTab()}
{activeTab === 'notification' && renderNotificationTab()}
{activeTab === 'database' && renderDatabaseTab()}
{activeTab === 'models' && renderModelsTab()}
{activeTab === 'cache' && renderCacheTab()}
{activeTab === 'api' && renderApiTab()}
{activeTab === 'analytics' && renderAnalyticsTab()}
{activeTab === 'security' && renderSecurityTab()}
{activeTab === 'about' && renderAboutTab()}
</div>
</div> </div>
</div> </div>
<div className="settings-tabs">
{tabs.map(tab => (
<button key={tab.id} className={`tab-btn ${activeTab === tab.id ? 'active' : ''}`} onClick={() => setActiveTab(tab.id)}>
<tab.icon size={16} />
<span>{tab.label}</span>
</button>
))}
</div>
<div className="settings-body">
{activeTab === 'appearance' && renderAppearanceTab()}
{activeTab === 'notification' && renderNotificationTab()}
{activeTab === 'database' && renderDatabaseTab()}
{activeTab === 'models' && renderModelsTab()}
{activeTab === 'cache' && renderCacheTab()}
{activeTab === 'api' && renderApiTab()}
{activeTab === 'analytics' && renderAnalyticsTab()}
{activeTab === 'security' && renderSecurityTab()}
{activeTab === 'about' && renderAboutTab()}
</div>
</div> </div>
) )
} }