feat(窗口管理): 添加自定义标题栏和窗口控制功能

实现自定义标题栏组件,包含最小化、最大化/还原和关闭按钮
添加窗口管理相关IPC通信接口
在设置页面增加开发者工具切换按钮
更新.gitignore忽略.trae目录
This commit is contained in:
Linkon
2026-01-18 20:40:29 +08:00
parent df5b94bdb4
commit a6f83fdfc3
8 changed files with 346 additions and 0 deletions
+1
View File
@@ -15,3 +15,4 @@ db.sqlite
!.vscode/extensions.json
!.vscode/launch.json
!.vscode/settings.json
/.trae/
+50
View File
@@ -62,6 +62,7 @@ export class WindowManager extends Service {
height: 670,
show: false,
autoHideMenuBar: true,
frame: false, // Custom title bar
icon: this.opts.icon,
title: input.title,
webPreferences: {
@@ -80,6 +81,14 @@ export class WindowManager extends Service {
win.show()
})
// Notify renderer about maximize state changes
win.on('maximize', () => {
win.webContents.send('window:maximized-changed', true)
})
win.on('unmaximize', () => {
win.webContents.send('window:maximized-changed', false)
})
win.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
@@ -142,5 +151,46 @@ export class WindowManager extends Service {
const ok = this.navigateWindow(win, route)
return ok ? { success: true } : { success: false, message: 'Window not found' }
})
// Window controls
this.mainCtx.handle('window:minimize', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (win) win.minimize()
})
this.mainCtx.handle('window:maximize', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (win) {
if (win.isMaximized()) {
win.unmaximize()
return false
} else {
win.maximize()
return true
}
}
return false
})
this.mainCtx.handle('window:close', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (win) win.close()
})
this.mainCtx.handle('window:isMaximized', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
return win ? win.isMaximized() : false
})
this.mainCtx.handle('window:toggle-devtools', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (win) {
if (win.webContents.isDevToolsOpened()) {
win.webContents.closeDevTools()
} else {
win.webContents.openDevTools()
}
}
})
}
}
+10
View File
@@ -73,6 +73,16 @@ const api = {
ipcRenderer.invoke('window:open', input),
navigateWindow: (input: { key?: string; route: string }) =>
ipcRenderer.invoke('window:navigate', input),
windowMinimize: () => ipcRenderer.invoke('window:minimize'),
windowMaximize: () => ipcRenderer.invoke('window:maximize'),
windowClose: () => ipcRenderer.invoke('window:close'),
windowIsMaximized: () => ipcRenderer.invoke('window:isMaximized'),
onWindowMaximizedChanged: (callback: (maximized: boolean) => void) => {
const subscription = (_event: any, maximized: boolean) => callback(maximized)
ipcRenderer.on('window:maximized-changed', subscription)
return () => ipcRenderer.removeListener('window:maximized-changed', subscription)
},
toggleDevTools: () => ipcRenderer.invoke('window:toggle-devtools'),
// Logger
queryLogs: (lines?: number) => ipcRenderer.invoke('log:query', lines),
+6
View File
@@ -116,6 +116,12 @@ export interface electronApi {
options?: any
}) => Promise<ipcResponse<void>>
navigateWindow: (input: { key?: string; route: string }) => Promise<ipcResponse<void>>
windowMinimize: () => Promise<void>
windowMaximize: () => Promise<boolean>
windowClose: () => Promise<void>
windowIsMaximized: () => Promise<boolean>
onWindowMaximizedChanged: (callback: (maximized: boolean) => void) => () => void
toggleDevTools: () => Promise<void>
// Logger
queryLogs: (lines?: number) => Promise<ipcResponse<string[]>>
+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW (OEM 版本) -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="57.5732mm" height="57.5732mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 5757.32 5757.32"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
<defs>
<style type="text/css">
<![CDATA[
.str0 {stroke:#FEFEFE;stroke-width:20;stroke-miterlimit:22.9256}
.str1 {stroke:#2387ED;stroke-width:20;stroke-miterlimit:22.9256}
.str2 {stroke:#2789ED;stroke-width:20;stroke-miterlimit:22.9256}
.fil0 {fill:#FEFEFE}
.fil1 {fill:#2389EC}
.fil4 {fill:#75BDF7}
.fil2 {fill:#FFDA31}
.fil3 {fill:#FFF270}
]]>
</style>
</defs>
<g id="圖層_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<g id="_1556526623248">
<rect class="fil0 str0" x="27.11" y="26.34" width="5675.33" height="5687.62" rx="1140.46" ry="1142.93"/>
<circle class="fil1 str1" cx="3484.74" cy="2200.34" r="920.68"/>
<circle class="fil0 str0" cx="3951.4" cy="3983.83" r="1288.52"/>
<g>
<circle class="fil2" cx="3951.4" cy="3983.83" r="1033.53"/>
<path class="fil3" d="M4139.76 3707.08c-14.94,-25.96 -43.54,-77.79 -66.66,-124.1 -23.11,-46.3 -40.75,-87.08 -61.13,-131.88 -20.37,-44.8 -43.48,-93.63 -73.05,-71.06 -29.57,22.56 -65.6,116.49 -95.71,187.21 -30.1,70.71 -54.3,118.2 -66.4,141.95 -12.1,23.74 -12.1,23.74 -12.1,23.74 0,0 0,0 -36.04,5.26 -36.03,5.25 -108.1,15.76 -187.6,26.12 -79.5,10.37 -166.42,20.58 -177.47,52.05 -11.04,31.46 53.79,84.19 113.27,132.93 59.48,48.74 113.6,93.5 140.66,115.88 27.06,22.38 27.06,22.38 27.06,22.38 0,0 0,0 -4.8,29.82 -4.81,29.82 -14.42,89.45 -29.87,166.02 -15.45,76.57 -36.73,170.06 -13.79,197.73 22.93,27.66 90.08,-10.5 157.94,-50.54 67.86,-40.04 136.43,-81.95 170.71,-102.91 34.28,-20.96 34.28,-20.96 34.28,-20.96 0,0 0,0 29.08,17.71 29.07,17.71 87.22,53.13 150.46,92.03 63.23,38.89 131.56,81.25 159.24,64.16 27.68,-17.1 14.73,-93.67 3.9,-169.09 -10.82,-75.42 -19.51,-149.7 -23.86,-186.84 -4.35,-37.13 -4.35,-37.13 58.7,-87.16 63.04,-50.02 189.13,-150.07 193.62,-207.02 4.48,-56.94 -112.64,-70.78 -173.29,-77.95 -60.65,-7.17 -64.83,-7.68 -79.61,-9.01 -14.78,-1.33 -40.17,-3.48 -61.94,-5.33 -21.77,-1.86 -39.91,-3.4 -49.64,-4.23 -9.72,-0.82 -11.01,-0.94 -25.96,-26.91z"/>
</g>
<ellipse class="fil0" cx="2140.85" cy="2197.59" rx="1194.99" ry="1178.43"/>
<circle class="fil4" cx="2124.15" cy="2207.61" r="923.37"/>
<path class="fil1 str2" d="M2788.24 3393.56c-11.75,24.43 -34.95,73.29 -54.62,123.82 -19.67,50.52 -35.81,102.71 -53.14,186.31 -17.34,83.6 -35.86,198.63 -29.23,317.47 6.63,118.84 38.41,241.5 66,328.24 27.59,86.75 50.98,137.57 62.68,162.99 11.7,25.41 11.7,25.41 11.7,25.41 0,0 0,0 -278.03,0 -278.03,0 -834.08,0 -1112.11,0 -278.02,0 -278.02,0 -291.55,0.23 -13.53,0.23 -40.59,0.69 -75.21,-7.93 -34.61,-8.62 -76.78,-26.31 -107.12,-64.94 -30.33,-38.64 -48.83,-98.21 -57.28,-137.12 -8.46,-38.92 -6.87,-57.19 -6.07,-82.24 0.79,-25.05 0.79,-56.9 0.79,-80.43 0,-23.54 0,-38.77 1.5,-69.12 1.5,-30.35 4.5,-75.83 9.34,-112.5 4.84,-36.68 11.52,-64.54 44.17,-133.73 32.66,-69.18 91.29,-179.68 171.72,-260.68 80.45,-81 182.69,-132.49 273.98,-164.28 91.29,-31.78 171.63,-43.86 218.71,-49.89 47.09,-6.04 60.94,-6.04 74.09,-6.04 13.15,0 25.61,0 212.92,0 187.31,0 549.47,0 730.87,0 181.4,0 182.04,0 182.71,0 0.67,0 1.37,0 3.04,0 1.68,0 4.31,0 6.31,0 2.01,0 3.37,0 3.98,0 0.62,0 0.48,0 0.72,0 0.24,0 0.86,0 1.02,0 0.16,0 -0.14,0 -11.89,24.43z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

+27
View File
@@ -501,6 +501,33 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
<div>{(window as any).electron?.process?.versions?.chrome || '-'}</div>
<div style={{ color: 'var(--ss-text-secondary)' }}>Node</div>
<div>{(window as any).electron?.process?.versions?.node || '-'}</div>
<div style={{ color: 'var(--ss-text-secondary)' }}>IPC </div>
<div>
<Tag
theme={(window as any).api ? 'success' : 'danger'}
variant="light"
size="small"
>
{(window as any).api ? '已连接' : '未连接 (Preload 失败)'}
</Tag>
</div>
<div style={{ color: 'var(--ss-text-secondary)' }}></div>
<div>
<Tag variant="outline" size="small">
{import.meta.env.DEV ? 'Development' : 'Production'}
</Tag>
</div>
</div>
<Divider />
<div style={{ marginTop: '16px' }}>
<Button
variant="outline"
onClick={() => {
;(window as any).api?.toggleDevTools()
}}
>
</Button>
</div>
</Card>
</Tabs.TabPanel>
+135
View File
@@ -0,0 +1,135 @@
import { Button } from 'tdesign-react'
import { RemoveIcon, RectangleIcon, CloseIcon, FullscreenExitIcon } from 'tdesign-icons-react'
import { useEffect, useState } from 'react'
import electronLogo from '../assets/electron.svg'
interface TitleBarProps {
children?: React.ReactNode
}
export function TitleBar({ children }: TitleBarProps): React.JSX.Element {
const [isMaximized, setIsMaximized] = useState(false)
useEffect(() => {
if (!(window as any).api) return // Check initial state
;(window as any).api.windowIsMaximized().then((v: boolean) => setIsMaximized(v))
// Subscribe to changes
const cleanup = (window as any).api.onWindowMaximizedChanged((maximized: boolean) => {
setIsMaximized(maximized)
})
return cleanup
}, [])
const minimize = () => {
;(window as any).api?.windowMinimize()
}
const maximize = async () => {
if (!(window as any).api) return
const newState = await (window as any).api.windowMaximize()
setIsMaximized(newState)
}
const close = () => {
;(window as any).api?.windowClose()
}
return (
<div
style={
{
height: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 0 0 16px',
backgroundColor: 'var(--ss-header-bg, #ffffff)',
borderBottom: '1px solid var(--ss-border-color, #e7e7e7)',
WebkitAppRegion: 'drag',
userSelect: 'none',
zIndex: 1000,
transition: 'background-color 0.3s, border-color 0.3s'
} as React.CSSProperties
}
>
<div
style={{
fontSize: '12px',
fontWeight: 600,
color: 'var(--ss-text-main, #000000)',
display: 'flex',
alignItems: 'center',
gap: '8px',
flexShrink: 0
}}
>
<img src={electronLogo} alt="logo" style={{ width: '16px', height: '16px' }} />
<span>SecScore</span>
</div>
<div style={{ flex: 1 }} />
<div
style={
{
display: 'flex',
alignItems: 'center',
height: '100%',
WebkitAppRegion: 'no-drag',
paddingRight: '12px'
} as React.CSSProperties
}
>
{children}
</div>
<div
style={
{
display: 'flex',
alignItems: 'center',
WebkitAppRegion: 'no-drag',
height: '100%',
flexShrink: 0
} as React.CSSProperties
}
>
<Button
variant="text"
shape="square"
onClick={minimize}
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
<RemoveIcon />
</Button>
<Button
variant="text"
shape="square"
onClick={maximize}
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
{isMaximized ? <FullscreenExitIcon /> : <RectangleIcon />}
</Button>
<Button
variant="text"
shape="square"
onClick={close}
className="titlebar-close-btn"
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
<CloseIcon />
</Button>
</div>
<style>
{`
.titlebar-close-btn:hover {
background-color: #e34d59 !important;
color: white !important;
}
`}
</style>
</div>
)
}
@@ -0,0 +1,80 @@
import { Button } from 'tdesign-react'
import { RemoveIcon, RectangleIcon, CloseIcon, FullscreenExitIcon } from 'tdesign-icons-react'
import { useEffect, useState } from 'react'
export function WindowControls(): React.JSX.Element {
const [isMaximized, setIsMaximized] = useState(false)
useEffect(() => {
if (!(window as any).api) return // Check initial state
;(window as any).api.windowIsMaximized().then((v: boolean) => setIsMaximized(v))
// Subscribe to changes
const cleanup = (window as any).api.onWindowMaximizedChanged((maximized: boolean) => {
setIsMaximized(maximized)
})
return cleanup
}, [])
const minimize = () => {
;(window as any).api?.windowMinimize()
}
const maximize = async () => {
if (!(window as any).api) return
const newState = await (window as any).api.windowMaximize()
setIsMaximized(newState)
}
const close = () => {
;(window as any).api?.windowClose()
}
return (
<div
style={
{
display: 'flex',
alignItems: 'center',
WebkitAppRegion: 'no-drag',
height: '100%',
flexShrink: 0
} as React.CSSProperties
}
>
<Button
variant="text"
shape="square"
onClick={minimize}
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
<RemoveIcon />
</Button>
<Button
variant="text"
shape="square"
onClick={maximize}
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
{isMaximized ? <FullscreenExitIcon /> : <RectangleIcon />}
</Button>
<Button
variant="text"
shape="square"
onClick={close}
className="titlebar-close-btn"
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
<CloseIcon />
</Button>
<style>
{`
.titlebar-close-btn:hover {
background-color: #e34d59 !important;
color: white !important;
}
`}
</style>
</div>
)
}