feat: 添加自定义 URL 协议支持及悬浮侧边栏显隐控制

- 新增 `secscore://` 自定义 URL 协议,支持通过链接直接导航至应用内页面(如 `secscore://settings`)和控制悬浮侧边栏(如 `secscore://sidebar/toggle`)
- 在系统托盘菜单中添加“显隐悬浮侧边栏”选项
- 在设置页面添加“URL 链接”选项卡,提供协议说明和手动注册按钮
- 应用支持单实例运行,后续协议调用将转发至现有实例
- 更新构建配置以注册 URL 协议,并添加详细的使用说明文档
- 修复 WindowManager 中可能因 settings 未初始化导致的潜在错误
This commit is contained in:
Fox_block
2026-01-23 11:06:27 +08:00
parent b9749fe0a8
commit 6d2346f3b1
9 changed files with 519 additions and 8 deletions
+185
View File
@@ -46,9 +46,170 @@ type mainAppConfig = {
window: windowManagerOptions
}
const PROTOCOL_SCHEME = 'secscore'
let mainCtxRef: MainContext | null = null
let pendingProtocolUrl: string | null = null
const extractProtocolUrl = (argv: string[]): string | null => {
const prefix = `${PROTOCOL_SCHEME}://`
const lowerPrefix = prefix.toLowerCase()
for (const arg of argv) {
if (typeof arg !== 'string') continue
const v = arg.trim()
if (!v) continue
const lower = v.toLowerCase()
if (lower.startsWith(lowerPrefix)) return v
}
return null
}
const openMainRoute = (ctx: MainContext, route: string) => {
ctx.windows.open({
key: 'main',
title: 'SecScore',
route
})
}
const handleProtocolUrl = (rawUrl: string, ctx: MainContext) => {
if (!rawUrl) return
let s = rawUrl.trim()
if (!s) return
const prefix = `${PROTOCOL_SCHEME}://`
if (s.toLowerCase().startsWith(prefix)) {
s = s.slice(prefix.length)
}
s = s.replace(/^\/+/, '')
if (!s) {
openMainRoute(ctx, '/')
return
}
const parts = s.split('/')
const head = parts[0]?.toLowerCase() ?? ''
const tail = parts.slice(1)
if (!head) {
openMainRoute(ctx, '/')
return
}
if (head === 'home') {
openMainRoute(ctx, '/')
return
}
if (head === 'students') {
openMainRoute(ctx, '/students')
return
}
if (head === 'score') {
openMainRoute(ctx, '/score')
return
}
if (head === 'leaderboard') {
openMainRoute(ctx, '/leaderboard')
return
}
if (head === 'settlements') {
openMainRoute(ctx, '/settlements')
return
}
if (head === 'reasons') {
openMainRoute(ctx, '/reasons')
return
}
if (head === 'settings') {
openMainRoute(ctx, '/settings')
return
}
if (head === 'sidebar') {
const action = tail[0]?.toLowerCase() ?? 'toggle'
const sidebarWin = ctx.windows.get('global-sidebar')
if (action === 'show') {
if (sidebarWin) {
sidebarWin.show()
sidebarWin.focus()
} else {
ctx.windows.open({
key: 'global-sidebar',
title: 'SecScore Sidebar',
route: '/global-sidebar',
options: {
transparent: true,
alwaysOnTop: true,
hasShadow: false,
type: 'toolbar'
}
})
}
return
}
if (action === 'hide') {
if (sidebarWin) {
sidebarWin.hide()
}
return
}
if (action === 'toggle') {
if (sidebarWin) {
if (sidebarWin.isVisible()) {
sidebarWin.hide()
} else {
sidebarWin.show()
sidebarWin.focus()
}
} else {
ctx.windows.open({
key: 'global-sidebar',
title: 'SecScore Sidebar',
route: '/global-sidebar',
options: {
transparent: true,
alwaysOnTop: true,
hasShadow: false,
type: 'toolbar'
}
})
}
return
}
}
}
const hasSingleInstanceLock = app.requestSingleInstanceLock()
if (!hasSingleInstanceLock) {
app.quit()
} else {
const initialUrl = extractProtocolUrl(process.argv)
if (initialUrl) {
pendingProtocolUrl = initialUrl
}
app.on('second-instance', (event, argv) => {
event.preventDefault()
const url = extractProtocolUrl(argv)
if (!url) return
if (mainCtxRef) {
handleProtocolUrl(url, mainCtxRef)
} else {
pendingProtocolUrl = url
}
})
app.on('open-url', (event, url) => {
event.preventDefault()
if (mainCtxRef) {
handleProtocolUrl(url, mainCtxRef)
} else {
pendingProtocolUrl = url
}
})
}
app.whenReady().then(async () => {
electronApp.setAppUserModelId('com.electron')
if (!is.dev) {
app.setAsDefaultProtocolClient(PROTOCOL_SCHEME)
}
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
@@ -181,8 +342,32 @@ app.whenReady().then(async () => {
const host = await builder.build()
const ctx = host.services.get(MainContext) as MainContext
mainCtxRef = ctx
ctx.handle('app:register-url-protocol', async () => {
if (is.dev) {
return { success: false, message: '仅在打包后的应用中可用' }
}
try {
const ok = app.setAsDefaultProtocolClient(PROTOCOL_SCHEME)
if (ok) {
return { success: true, data: { registered: true } }
}
return { success: false, data: { registered: false }, message: '系统未接受协议注册' }
} catch (error) {
const message =
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'
return { success: false, message: `注册失败: ${message}` }
}
})
await host.start()
if (pendingProtocolUrl) {
handleProtocolUrl(pendingProtocolUrl, ctx)
pendingProtocolUrl = null
}
let disposing = false
const beforeQuitHandler = () => {
if (disposing) return
+27 -1
View File
@@ -28,9 +28,35 @@ export class TrayService extends Service {
this.showMainWindow()
}
},
{
label: '显隐悬浮侧边栏',
click: () => {
const sidebarWin = this.mainCtx.windows.get('global-sidebar')
if (sidebarWin) {
if (sidebarWin.isVisible()) {
sidebarWin.hide()
} else {
sidebarWin.show()
sidebarWin.focus()
}
} else {
this.mainCtx.windows.open({
key: 'global-sidebar',
title: 'SecScore Sidebar',
route: '/global-sidebar',
options: {
transparent: true,
alwaysOnTop: true,
hasShadow: false,
type: 'toolbar'
}
})
}
}
},
{ type: 'separator' },
{
label: '退出 SecScore',
label: '关闭应用',
click: () => {
app.quit()
}
+7 -5
View File
@@ -135,7 +135,8 @@ export class WindowManager extends Service {
win.setResizable(false)
}
const zoom = Number(this.mainCtx.settings.getValue('window_zoom')) || 1.0
const zoomSettings = this.mainCtx.settings
const zoom = zoomSettings ? Number(zoomSettings.getValue('window_zoom')) || 1.0 : 1.0
win.webContents.setZoomFactor(zoom)
this.windows.set(input.key, win)
@@ -186,9 +187,10 @@ export class WindowManager extends Service {
private applyMicaEffect(win: BrowserWindow) {
if (!micaElectron) return
const settings = this.mainCtx.settings
if (!settings) return
const micaWin = win as MicaWindow
const theme = this.mainCtx.settings.getValue('window_theme')
const theme = settings.getValue('window_theme')
switch (theme) {
case 'dark':
micaWin.setDarkTheme()
@@ -200,7 +202,7 @@ export class WindowManager extends Service {
micaWin.setAutoTheme()
}
const effect = this.mainCtx.settings.getValue('window_effect')
const effect = settings.getValue('window_effect')
switch (effect) {
case 'mica':
micaWin.setMicaEffect()
@@ -227,7 +229,7 @@ export class WindowManager extends Service {
break
}
const radius = this.mainCtx.settings.getValue('window_radius')
const radius = settings.getValue('window_radius')
switch (radius) {
case 'small':
micaWin.setSmallRoundedCorner()
+3 -1
View File
@@ -101,7 +101,9 @@ const api = {
clearLogs: () => ipcRenderer.invoke('log:clear'),
setLogLevel: (level: string) => ipcRenderer.invoke('log:setLevel', level),
writeLog: (payload: { level: string; message: string; meta?: any }) =>
ipcRenderer.invoke('log:write', payload)
ipcRenderer.invoke('log:write', payload),
registerUrlProtocol: () => ipcRenderer.invoke('app:register-url-protocol')
}
if (process.contextIsolated) {
+2
View File
@@ -150,4 +150,6 @@ export interface electronApi {
message: string
meta?: any
}) => Promise<ipcResponse<void>>
registerUrlProtocol: () => Promise<ipcResponse<{ registered?: boolean }>>
}
+58
View File
@@ -60,6 +60,8 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
const [settleLoading, setSettleLoading] = useState(false)
const [settleDialogVisible, setSettleDialogVisible] = useState(false)
const [urlRegisterLoading, setUrlRegisterLoading] = useState(false)
const canAdmin = permission === 'admin'
const permissionTag = useMemo(() => {
@@ -535,6 +537,62 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
</Card>
</Tabs.TabPanel>
<Tabs.TabPanel value="url" label="URL 链接">
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
<div style={{ fontSize: '16px', fontWeight: 600, marginBottom: '8px' }}>
URL (secscore://)
</div>
<Divider />
<div style={{ marginBottom: '12px', fontSize: '13px', color: 'var(--ss-text-secondary)' }}>
URL SecScore
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '4px',
fontSize: '12px',
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", monospace'
}}
>
<div>secscore://settings</div>
<div>secscore://score</div>
<div>secscore://sidebar/toggle</div>
</div>
<Divider />
<Space>
<Button
theme="primary"
loading={urlRegisterLoading}
disabled={!canAdmin}
onClick={async () => {
if (!(window as any).api) return
setUrlRegisterLoading(true)
const res = await (window as any).api.registerUrlProtocol()
setUrlRegisterLoading(false)
if (res && res.success) {
MessagePlugin.success('URL 协议已注册')
} else {
MessagePlugin.error(res?.message || '注册失败')
}
}}
>
URL
</Button>
</Space>
<div
style={{
marginTop: '8px',
fontSize: '12px',
color: 'var(--ss-text-secondary)'
}}
>
SecScore
</div>
</Card>
</Tabs.TabPanel>
<Tabs.TabPanel value="about" label="关于">
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
<div style={{ fontSize: '16px', fontWeight: 700, marginBottom: '8px' }}>SecScore</div>