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
+230
View File
@@ -0,0 +1,230 @@
# SecScore URL 协议说明
## 1. 概述
SecScore 提供自定义 URL 协议,用于从浏览器、命令行或其他应用中直接调用软件功能。
- 协议名(scheme):`secscore`
- URL 基本格式:
```text
secscore://<command>[/<subcommand>]
```
- 大小写约定:
- 协议名、命令部分大小写不敏感(`SecScore://settings` 与 `secscore://SETTINGS` 等效)
- 建议实际使用中统一采用小写
- 行为约定:
- 若 SecScore **未运行**:启动应用,并根据 URL 执行对应操作
- 若 SecScore **已运行**:不会启动第二个实例,而是把 URL 转发给现有实例执行
---
## 2. 页面导航类 URL
用于打开主窗口并跳转到指定功能页面。
### 2.1 首页
- URL
```text
secscore://
secscore://home
```
- 行为:
- 打开主窗口,跳转到 `/`(首页)
### 2.2 学生管理
- URL
```text
secscore://students
```
- 行为:
- 打开主窗口,跳转到 `/students`
### 2.3 积分管理
- URL
```text
secscore://score
```
- 行为:
- 打开主窗口,跳转到 `/score`
### 2.4 排行榜
- URL
```text
secscore://leaderboard
```
- 行为:
- 打开主窗口,跳转到 `/leaderboard`
### 2.5 结算历史
- URL
```text
secscore://settlements
```
- 行为:
- 打开主窗口,跳转到 `/settlements`
### 2.6 理由管理
- URL
```text
secscore://reasons
```
- 行为:
- 打开主窗口,跳转到 `/reasons`
### 2.7 系统设置
- URL
```text
secscore://settings
```
- 行为:
- 打开主窗口,跳转到 `/settings`
---
## 3. 悬浮侧边栏控制类 URL
用于控制右侧的全局悬浮侧边栏(`global-sidebar` 窗口)。
### 3.1 显示悬浮侧边栏
- URL
```text
secscore://sidebar/show
```
- 行为:
- 若悬浮侧边栏已存在:`show()` + `focus()`
- 若不存在:创建 `global-sidebar` 窗口并显示
### 3.2 隐藏悬浮侧边栏
- URL
```text
secscore://sidebar/hide
```
- 行为:
- 若悬浮侧边栏存在:调用 `hide()`
- 若不存在:不做任何操作
### 3.3 切换显隐(toggle
- URL
```text
secscore://sidebar/toggle
secscore://sidebar
```
- 行为:
- 若悬浮侧边栏存在:
- 当前可见 → 隐藏
- 当前不可见 → 显示并 `focus()`
- 若不存在:创建并显示 `global-sidebar` 窗口
---
## 4. 使用示例(Windows
### 4.1 通过“运行”对话框(Win + R)
在“运行”窗口中输入:
```text
secscore://settings
secscore://sidebar/toggle
```
点击“确定”后,SecScore 会执行对应操作。
### 4.2 通过浏览器地址栏
在浏览器(如 Edge)地址栏中输入:
```text
secscore://score
```
浏览器会唤起 SecScore 应用并跳转到“积分管理”。
### 4.3 通过命令行
在命令行中执行:
```bash
start "" "secscore://leaderboard"
```
会唤起 SecScore 并打开“排行榜”。
---
## 5. 安装与注册说明(打包版本)
> 下述内容针对打包后的安装版本,开发模式(`pnpm dev`)下可能不会自动注册协议。
- 协议注册配置位于 `electron-builder.yml`
- `scheme`: `secscore`
- `name`: `SecScore URL Protocol`
- 在安装完成后,操作系统会把所有 `secscore://` URL 交给 SecScore 处理。
---
## 6. 错误与兼容性说明
- 所有 URL 都为本地调用,不通过网络传输。
- 当前 URL 协议只包含页面级操作与悬浮侧边栏控制,不带参数(例如学生 ID)。
- 对于未识别的命令,例如:
```text
secscore://unknown
```
主进程会忽略该 URL,不会抛出错误弹窗。
---
## 7. 后续扩展建议
如果需要更复杂的 URL 功能,可以在保持现有兼容性的前提下扩展,例如:
- 按学生 ID 打开积分记录:
```text
secscore://score/12345
```
- 带筛选条件的排行榜视图:
```text
secscore://leaderboard/today
```
此类扩展只需在主进程的 URL 解析函数中增加相应分支逻辑即可。
+5
View File
@@ -2,6 +2,11 @@ appId: com.secscore.app
productName: SecScore productName: SecScore
directories: directories:
buildResources: build buildResources: build
protocols:
- name: SecScore URL Protocol
schemes:
- secscore
role: Viewer
files: files:
- '!**/.vscode/*' - '!**/.vscode/*'
- '!src/*' - '!src/*'
+2 -1
View File
@@ -30,7 +30,8 @@ export default defineConfig(
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'react-refresh/only-export-components': 'off', 'react-refresh/only-export-components': 'off',
'react-hooks/exhaustive-deps': 'warn', 'react-hooks/exhaustive-deps': 'warn',
'react-hooks/set-state-in-effect': 'off' 'react-hooks/set-state-in-effect': 'off',
'@typescript-eslint/no-require-imports': 'off'
} }
}, },
eslintConfigPrettier eslintConfigPrettier
+185
View File
@@ -46,9 +46,170 @@ type mainAppConfig = {
window: windowManagerOptions 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 () => { app.whenReady().then(async () => {
electronApp.setAppUserModelId('com.electron') electronApp.setAppUserModelId('com.electron')
if (!is.dev) {
app.setAsDefaultProtocolClient(PROTOCOL_SCHEME)
}
app.on('browser-window-created', (_, window) => { app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window) optimizer.watchWindowShortcuts(window)
}) })
@@ -181,8 +342,32 @@ app.whenReady().then(async () => {
const host = await builder.build() const host = await builder.build()
const ctx = host.services.get(MainContext) as MainContext 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() await host.start()
if (pendingProtocolUrl) {
handleProtocolUrl(pendingProtocolUrl, ctx)
pendingProtocolUrl = null
}
let disposing = false let disposing = false
const beforeQuitHandler = () => { const beforeQuitHandler = () => {
if (disposing) return if (disposing) return
+27 -1
View File
@@ -28,9 +28,35 @@ export class TrayService extends Service {
this.showMainWindow() 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' }, { type: 'separator' },
{ {
label: '退出 SecScore', label: '关闭应用',
click: () => { click: () => {
app.quit() app.quit()
} }
+7 -5
View File
@@ -135,7 +135,8 @@ export class WindowManager extends Service {
win.setResizable(false) 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) win.webContents.setZoomFactor(zoom)
this.windows.set(input.key, win) this.windows.set(input.key, win)
@@ -186,9 +187,10 @@ export class WindowManager extends Service {
private applyMicaEffect(win: BrowserWindow) { private applyMicaEffect(win: BrowserWindow) {
if (!micaElectron) return if (!micaElectron) return
const settings = this.mainCtx.settings
if (!settings) return
const micaWin = win as MicaWindow const micaWin = win as MicaWindow
const theme = settings.getValue('window_theme')
const theme = this.mainCtx.settings.getValue('window_theme')
switch (theme) { switch (theme) {
case 'dark': case 'dark':
micaWin.setDarkTheme() micaWin.setDarkTheme()
@@ -200,7 +202,7 @@ export class WindowManager extends Service {
micaWin.setAutoTheme() micaWin.setAutoTheme()
} }
const effect = this.mainCtx.settings.getValue('window_effect') const effect = settings.getValue('window_effect')
switch (effect) { switch (effect) {
case 'mica': case 'mica':
micaWin.setMicaEffect() micaWin.setMicaEffect()
@@ -227,7 +229,7 @@ export class WindowManager extends Service {
break break
} }
const radius = this.mainCtx.settings.getValue('window_radius') const radius = settings.getValue('window_radius')
switch (radius) { switch (radius) {
case 'small': case 'small':
micaWin.setSmallRoundedCorner() micaWin.setSmallRoundedCorner()
+3 -1
View File
@@ -101,7 +101,9 @@ const api = {
clearLogs: () => ipcRenderer.invoke('log:clear'), clearLogs: () => ipcRenderer.invoke('log:clear'),
setLogLevel: (level: string) => ipcRenderer.invoke('log:setLevel', level), setLogLevel: (level: string) => ipcRenderer.invoke('log:setLevel', level),
writeLog: (payload: { level: string; message: string; meta?: any }) => 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) { if (process.contextIsolated) {
+2
View File
@@ -150,4 +150,6 @@ export interface electronApi {
message: string message: string
meta?: any meta?: any
}) => Promise<ipcResponse<void>> }) => 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 [settleLoading, setSettleLoading] = useState(false)
const [settleDialogVisible, setSettleDialogVisible] = useState(false) const [settleDialogVisible, setSettleDialogVisible] = useState(false)
const [urlRegisterLoading, setUrlRegisterLoading] = useState(false)
const canAdmin = permission === 'admin' const canAdmin = permission === 'admin'
const permissionTag = useMemo(() => { const permissionTag = useMemo(() => {
@@ -535,6 +537,62 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
</Card> </Card>
</Tabs.TabPanel> </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="关于"> <Tabs.TabPanel value="about" label="关于">
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}> <Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
<div style={{ fontSize: '16px', fontWeight: 700, marginBottom: '8px' }}>SecScore</div> <div style={{ fontSize: '16px', fontWeight: 700, marginBottom: '8px' }}>SecScore</div>