mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 12:34:22 +08:00
feat: 添加自定义 URL 协议支持及悬浮侧边栏显隐控制
- 新增 `secscore://` 自定义 URL 协议,支持通过链接直接导航至应用内页面(如 `secscore://settings`)和控制悬浮侧边栏(如 `secscore://sidebar/toggle`) - 在系统托盘菜单中添加“显隐悬浮侧边栏”选项 - 在设置页面添加“URL 链接”选项卡,提供协议说明和手动注册按钮 - 应用支持单实例运行,后续协议调用将转发至现有实例 - 更新构建配置以注册 URL 协议,并添加详细的使用说明文档 - 修复 WindowManager 中可能因 settings 未初始化导致的潜在错误
This commit is contained in:
@@ -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 解析函数中增加相应分支逻辑即可。
|
||||
|
||||
@@ -2,6 +2,11 @@ appId: com.secscore.app
|
||||
productName: SecScore
|
||||
directories:
|
||||
buildResources: build
|
||||
protocols:
|
||||
- name: SecScore URL Protocol
|
||||
schemes:
|
||||
- secscore
|
||||
role: Viewer
|
||||
files:
|
||||
- '!**/.vscode/*'
|
||||
- '!src/*'
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@ export default defineConfig(
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'react-refresh/only-export-components': 'off',
|
||||
'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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -150,4 +150,6 @@ export interface electronApi {
|
||||
message: string
|
||||
meta?: any
|
||||
}) => Promise<ipcResponse<void>>
|
||||
|
||||
registerUrlProtocol: () => Promise<ipcResponse<{ registered?: boolean }>>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user