mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
feat(全局侧边栏): 添加全局侧边栏功能及窗口导航优化
- 新增全局侧边栏组件,支持展开/收起和快速导航功能 - 添加窗口大小调整和导航事件通信接口 - 优化窗口导航逻辑,使用软导航避免页面重载 - 调整主窗口启动逻辑,默认打开全局侧边栏
This commit is contained in:
+11
-93
@@ -165,100 +165,18 @@ app.whenReady().then(async () => {
|
|||||||
const tray = services.get(TrayServiceToken) as TrayService
|
const tray = services.get(TrayServiceToken) as TrayService
|
||||||
tray.initialize()
|
tray.initialize()
|
||||||
|
|
||||||
const logLevelSetting = ctx.settings.getValue('log_level') as logLevel
|
// Open Global Sidebar on startup
|
||||||
if (logLevelSetting) {
|
ctx.windows.open({
|
||||||
ctx.logger.setLevel(logLevelSetting)
|
key: 'global-sidebar',
|
||||||
}
|
title: 'SecScore Sidebar',
|
||||||
|
route: '/global-sidebar',
|
||||||
ctx.logger.info('Application starting...')
|
options: {
|
||||||
|
transparent: true,
|
||||||
const mainConsole = console as any
|
alwaysOnTop: true,
|
||||||
mainConsole.log = (...args: any[]) => ctx.logger.info(String(args[0] ?? ''), ...args.slice(1))
|
hasShadow: false,
|
||||||
mainConsole.info = (...args: any[]) =>
|
type: 'toolbar'
|
||||||
ctx.logger.info(String(args[0] ?? ''), ...args.slice(1))
|
|
||||||
mainConsole.warn = (...args: any[]) =>
|
|
||||||
ctx.logger.warn(String(args[0] ?? ''), ...args.slice(1))
|
|
||||||
mainConsole.error = (...args: any[]) =>
|
|
||||||
ctx.logger.error(String(args[0] ?? ''), ...args.slice(1))
|
|
||||||
mainConsole.debug = (...args: any[]) =>
|
|
||||||
ctx.logger.debug(String(args[0] ?? ''), ...args.slice(1))
|
|
||||||
mainConsole.trace = (...args: any[]) =>
|
|
||||||
ctx.logger.debug('console.trace', { args, stack: new Error('console.trace').stack })
|
|
||||||
|
|
||||||
if (!config.isDev) {
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(config.themeDir)) {
|
|
||||||
fs.mkdirSync(config.themeDir, { recursive: true })
|
|
||||||
}
|
|
||||||
const existing = fs
|
|
||||||
.readdirSync(config.themeDir)
|
|
||||||
.filter((f) => f.toLowerCase().endsWith('.json'))
|
|
||||||
if (existing.length === 0) {
|
|
||||||
const builtinThemeDir = join(app.getAppPath(), 'themes')
|
|
||||||
if (fs.existsSync(builtinThemeDir)) {
|
|
||||||
const files = fs
|
|
||||||
.readdirSync(builtinThemeDir)
|
|
||||||
.filter((f) => f.toLowerCase().endsWith('.json'))
|
|
||||||
for (const f of files) {
|
|
||||||
const src = join(builtinThemeDir, f)
|
|
||||||
const dest = join(config.themeDir, f)
|
|
||||||
try {
|
|
||||||
fs.copyFileSync(src, dest)
|
|
||||||
} catch (e: any) {
|
|
||||||
ctx.logger.warn('Failed to copy builtin theme', {
|
|
||||||
src,
|
|
||||||
dest,
|
|
||||||
message: e?.message
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
ctx.logger.warn('Failed to initialize theme directory', { message: e?.message })
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
const uncaughtExceptionHandler = (err: any) => {
|
|
||||||
ctx.logger.error('uncaughtException', {
|
|
||||||
message: err?.message,
|
|
||||||
stack: err?.stack
|
|
||||||
})
|
|
||||||
}
|
|
||||||
process.on('uncaughtException', uncaughtExceptionHandler)
|
|
||||||
ctx.effect(() => process.removeListener('uncaughtException', uncaughtExceptionHandler))
|
|
||||||
|
|
||||||
const unhandledRejectionHandler = (reason: any) => {
|
|
||||||
if (reason instanceof Error) {
|
|
||||||
ctx.logger.error('unhandledRejection', { message: reason.message, stack: reason.stack })
|
|
||||||
} else {
|
|
||||||
ctx.logger.error('unhandledRejection', reason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
process.on('unhandledRejection', unhandledRejectionHandler)
|
|
||||||
ctx.effect(() => process.removeListener('unhandledRejection', unhandledRejectionHandler))
|
|
||||||
|
|
||||||
const renderProcessGoneHandler = (_: any, __: any, details: any) => {
|
|
||||||
ctx.logger.error('render-process-gone', details)
|
|
||||||
}
|
|
||||||
app.on('render-process-gone', renderProcessGoneHandler)
|
|
||||||
ctx.effect(() => app.removeListener('render-process-gone', renderProcessGoneHandler))
|
|
||||||
|
|
||||||
const childProcessGoneHandler = (_: any, details: any) => {
|
|
||||||
ctx.logger.error('child-process-gone', details)
|
|
||||||
}
|
|
||||||
app.on('child-process-gone', childProcessGoneHandler)
|
|
||||||
ctx.effect(() => app.removeListener('child-process-gone', childProcessGoneHandler))
|
|
||||||
|
|
||||||
ctx.windows.open({ key: 'main', title: 'SecScore', route: '/' })
|
|
||||||
|
|
||||||
const activateHandler = () => {
|
|
||||||
if (!ctx.windows.get('main')) {
|
|
||||||
ctx.windows.open({ key: 'main', title: 'SecScore', route: '/' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
app.on('activate', activateHandler)
|
|
||||||
ctx.effect(() => app.removeListener('activate', activateHandler))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const host = await builder.build()
|
const host = await builder.build()
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ export class WindowManager extends Service {
|
|||||||
public open(input: windowOpenInput) {
|
public open(input: windowOpenInput) {
|
||||||
const existing = this.get(input.key)
|
const existing = this.get(input.key)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (input.route) void this.loadRoute(existing, input.route)
|
if (input.route) {
|
||||||
|
// Use soft navigation if window exists to prevent reload
|
||||||
|
existing.webContents.send('app:navigate', input.route)
|
||||||
|
}
|
||||||
existing.show()
|
existing.show()
|
||||||
existing.focus()
|
existing.focus()
|
||||||
return existing
|
return existing
|
||||||
@@ -72,6 +75,25 @@ export class WindowManager extends Service {
|
|||||||
...input.options
|
...input.options
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Special positioning for global sidebar
|
||||||
|
if (input.key === 'global-sidebar') {
|
||||||
|
const { screen } = require('electron')
|
||||||
|
const primaryDisplay = screen.getPrimaryDisplay()
|
||||||
|
const { width, height } = primaryDisplay.workAreaSize
|
||||||
|
const winWidth = 84
|
||||||
|
const winHeight = 300
|
||||||
|
win.setBounds({
|
||||||
|
x: width - winWidth,
|
||||||
|
y: Math.floor(height / 2 - winHeight / 2),
|
||||||
|
width: winWidth,
|
||||||
|
height: winHeight
|
||||||
|
})
|
||||||
|
win.setAlwaysOnTop(true, 'screen-saver')
|
||||||
|
win.setVisibleOnAllWorkspaces(true)
|
||||||
|
win.setSkipTaskbar(true)
|
||||||
|
win.setResizable(false)
|
||||||
|
}
|
||||||
|
|
||||||
const zoom = Number(this.mainCtx.settings.getValue('window_zoom')) || 1.0
|
const zoom = Number(this.mainCtx.settings.getValue('window_zoom')) || 1.0
|
||||||
win.webContents.setZoomFactor(zoom)
|
win.webContents.setZoomFactor(zoom)
|
||||||
|
|
||||||
@@ -112,13 +134,13 @@ export class WindowManager extends Service {
|
|||||||
public navigate(key: string, route: string) {
|
public navigate(key: string, route: string) {
|
||||||
const win = this.get(key)
|
const win = this.get(key)
|
||||||
if (!win) return false
|
if (!win) return false
|
||||||
void this.loadRoute(win, route)
|
win.webContents.send('app:navigate', route)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
public navigateWindow(win: BrowserWindow, route: string) {
|
public navigateWindow(win: BrowserWindow, route: string) {
|
||||||
if (win.isDestroyed()) return false
|
if (win.isDestroyed()) return false
|
||||||
void this.loadRoute(win, route)
|
win.webContents.send('app:navigate', route)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,5 +232,20 @@ export class WindowManager extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.mainCtx.handle('window:resize', (event, width: number, height: number) => {
|
||||||
|
const win = BrowserWindow.fromWebContents(event.sender)
|
||||||
|
if (win) {
|
||||||
|
const bounds = win.getBounds()
|
||||||
|
// Keep right side pinned
|
||||||
|
const newX = bounds.x + (bounds.width - width)
|
||||||
|
win.setBounds({
|
||||||
|
x: newX,
|
||||||
|
y: bounds.y,
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,13 @@ const api = {
|
|||||||
ipcRenderer.on('window:maximized-changed', subscription)
|
ipcRenderer.on('window:maximized-changed', subscription)
|
||||||
return () => ipcRenderer.removeListener('window:maximized-changed', subscription)
|
return () => ipcRenderer.removeListener('window:maximized-changed', subscription)
|
||||||
},
|
},
|
||||||
|
onNavigate: (callback: (route: string) => void) => {
|
||||||
|
const subscription = (_event: any, route: string) => callback(route)
|
||||||
|
ipcRenderer.on('app:navigate', subscription)
|
||||||
|
return () => ipcRenderer.removeListener('app:navigate', subscription)
|
||||||
|
},
|
||||||
toggleDevTools: () => ipcRenderer.invoke('window:toggle-devtools'),
|
toggleDevTools: () => ipcRenderer.invoke('window:toggle-devtools'),
|
||||||
|
windowResize: (width: number, height: number) => ipcRenderer.invoke('window:resize', width, height),
|
||||||
|
|
||||||
// Logger
|
// Logger
|
||||||
queryLogs: (lines?: number) => ipcRenderer.invoke('log:query', lines),
|
queryLogs: (lines?: number) => ipcRenderer.invoke('log:query', lines),
|
||||||
|
|||||||
@@ -122,7 +122,9 @@ export interface electronApi {
|
|||||||
windowClose: () => Promise<void>
|
windowClose: () => Promise<void>
|
||||||
windowIsMaximized: () => Promise<boolean>
|
windowIsMaximized: () => Promise<boolean>
|
||||||
onWindowMaximizedChanged: (callback: (maximized: boolean) => void) => () => void
|
onWindowMaximizedChanged: (callback: (maximized: boolean) => void) => () => void
|
||||||
|
onNavigate: (callback: (route: string) => void) => () => void
|
||||||
toggleDevTools: () => Promise<void>
|
toggleDevTools: () => Promise<void>
|
||||||
|
windowResize: (width: number, height: number) => Promise<void>
|
||||||
|
|
||||||
// Logger
|
// Logger
|
||||||
queryLogs: (lines?: number) => Promise<ipcResponse<string[]>>
|
queryLogs: (lines?: number) => Promise<ipcResponse<string[]>>
|
||||||
|
|||||||
@@ -1,15 +1,30 @@
|
|||||||
import { Layout, Dialog, Input, MessagePlugin } from 'tdesign-react'
|
import { Layout, Dialog, Input, MessagePlugin } from 'tdesign-react'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { HashRouter, useLocation, useNavigate } from 'react-router-dom'
|
import { HashRouter, useLocation, useNavigate, Routes, Route } from 'react-router-dom'
|
||||||
import { Sidebar } from './components/Sidebar'
|
import { Sidebar } from './components/Sidebar'
|
||||||
import { ContentArea } from './components/ContentArea'
|
import { ContentArea } from './components/ContentArea'
|
||||||
import { Wizard } from './components/Wizard'
|
import { Wizard } from './components/Wizard'
|
||||||
import { ThemeProvider } from './contexts/ThemeContext'
|
import { ThemeProvider } from './contexts/ThemeContext'
|
||||||
|
import { GlobalSidebar } from './components/GlobalSidebar'
|
||||||
|
|
||||||
function MainContent(): React.JSX.Element {
|
function MainContent(): React.JSX.Element {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!(window as any).api) return
|
||||||
|
const unlisten = (window as any).api.onNavigate((route: string) => {
|
||||||
|
// 统一路径格式进行比对,防止 / 和 /home 导致重复跳转
|
||||||
|
const currentPath = location.pathname === '/' ? '/home' : location.pathname
|
||||||
|
const targetPath = route === '/' ? '/home' : route
|
||||||
|
|
||||||
|
if (currentPath !== targetPath) {
|
||||||
|
navigate(route)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => unlisten()
|
||||||
|
}, [navigate, location.pathname])
|
||||||
|
|
||||||
const [wizardVisible, setWizardVisible] = useState(false)
|
const [wizardVisible, setWizardVisible] = useState(false)
|
||||||
const [permission, setPermission] = useState<'admin' | 'points' | 'view'>('view')
|
const [permission, setPermission] = useState<'admin' | 'points' | 'view'>('view')
|
||||||
const [hasAnyPassword, setHasAnyPassword] = useState(false)
|
const [hasAnyPassword, setHasAnyPassword] = useState(false)
|
||||||
@@ -129,7 +144,10 @@ function App(): React.JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<MainContent />
|
<Routes>
|
||||||
|
<Route path="/global-sidebar" element={<GlobalSidebar />} />
|
||||||
|
<Route path="/*" element={<MainContent />} />
|
||||||
|
</Routes>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -96,3 +96,40 @@ html[theme-mode='dark'] .ss-sidebar .t-menu__item.t-is-active svg:not([fill='non
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Global Sidebar Toggle Button */
|
||||||
|
.global-sidebar-toggle {
|
||||||
|
width: 24px;
|
||||||
|
height: 60px;
|
||||||
|
background-color: var(--ss-card-bg);
|
||||||
|
border-radius: 8px 0 0 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: -2px 0 8px rgba(0,0,0,0.1);
|
||||||
|
border: 1px solid var(--ss-border-color);
|
||||||
|
border-right: none;
|
||||||
|
color: var(--ss-text-main);
|
||||||
|
z-index: 10;
|
||||||
|
flex-shrink: 0;
|
||||||
|
animation: twinkle-animation 3s infinite ease-in-out;
|
||||||
|
transition: background-color 0.3s, color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-sidebar-toggle:hover {
|
||||||
|
background-color: var(--ss-item-hover);
|
||||||
|
color: var(--td-brand-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes twinkle-animation {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: -2px 0 8px rgba(0,0,0,0.1);
|
||||||
|
filter: brightness(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: -4px 0 12px var(--td-brand-color-light), 0 0 15px var(--td-brand-color-focus);
|
||||||
|
filter: brightness(1.2);
|
||||||
|
color: var(--td-brand-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { Button, Tooltip } from 'tdesign-react'
|
||||||
|
import {
|
||||||
|
HomeIcon,
|
||||||
|
ViewListIcon,
|
||||||
|
UserAddIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
SettingIcon
|
||||||
|
} from 'tdesign-icons-react'
|
||||||
|
|
||||||
|
export const GlobalSidebar: React.FC = () => {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!(window as any).api) return
|
||||||
|
if (expanded) {
|
||||||
|
(window as any).api.windowResize(84, 300)
|
||||||
|
} else {
|
||||||
|
(window as any).api.windowResize(24, 300)
|
||||||
|
}
|
||||||
|
}, [expanded])
|
||||||
|
|
||||||
|
const openMain = () => {
|
||||||
|
if (!(window as any).api) return
|
||||||
|
(window as any).api.openWindow({ key: 'main', route: '/' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const openQuickPoint = () => {
|
||||||
|
// 统一使用 / 路径,因为主页即是积分操作页
|
||||||
|
openMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openLeaderboard = () => {
|
||||||
|
if (!(window as any).api) return
|
||||||
|
(window as any).api.openWindow({ key: 'main', route: '/leaderboard' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`global-sidebar-container ${expanded ? 'expanded' : 'collapsed'}`}
|
||||||
|
style={{
|
||||||
|
height: '100vh',
|
||||||
|
width: '84px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: 'transparent',
|
||||||
|
userSelect: 'none',
|
||||||
|
position: 'fixed',
|
||||||
|
right: 0,
|
||||||
|
top: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
transition: 'transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||||
|
transform: expanded ? 'translateX(0)' : 'translateX(60px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 侧边栏内容区 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
backgroundColor: 'var(--ss-card-bg)',
|
||||||
|
borderRadius: '12px 0 0 12px',
|
||||||
|
boxShadow: '-4px 0 16px rgba(0,0,0,0.15)',
|
||||||
|
border: '1px solid var(--ss-border-color)',
|
||||||
|
borderRight: 'none',
|
||||||
|
padding: '12px 8px',
|
||||||
|
gap: '12px',
|
||||||
|
width: '60px',
|
||||||
|
pointerEvents: expanded ? 'auto' : 'none',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip content="主界面" placement="left">
|
||||||
|
<Button shape="circle" variant="text" onClick={openMain}>
|
||||||
|
<HomeIcon size="24px" />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip content="积分操作" placement="left">
|
||||||
|
<Button shape="circle" variant="text" onClick={openQuickPoint}>
|
||||||
|
<UserAddIcon size="24px" />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip content="排行榜" placement="left">
|
||||||
|
<Button shape="circle" variant="text" onClick={openLeaderboard}>
|
||||||
|
<ViewListIcon size="24px" />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip content="设置" placement="left">
|
||||||
|
<Button shape="circle" variant="text" onClick={() => (window as any).api.openWindow({ key: 'main', route: '/settings' })}>
|
||||||
|
<SettingIcon size="24px" />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 展开/收起手柄 */}
|
||||||
|
<div
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="global-sidebar-toggle"
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user