From 6d2346f3b19bcc74888f8916f1f34379b604dac6 Mon Sep 17 00:00:00 2001 From: Fox_block Date: Fri, 23 Jan 2026 11:06:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=20URL=20=E5=8D=8F=E8=AE=AE=E6=94=AF=E6=8C=81=E5=8F=8A?= =?UTF-8?q?=E6=82=AC=E6=B5=AE=E4=BE=A7=E8=BE=B9=E6=A0=8F=E6=98=BE=E9=9A=90?= =?UTF-8?q?=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `secscore://` 自定义 URL 协议,支持通过链接直接导航至应用内页面(如 `secscore://settings`)和控制悬浮侧边栏(如 `secscore://sidebar/toggle`) - 在系统托盘菜单中添加“显隐悬浮侧边栏”选项 - 在设置页面添加“URL 链接”选项卡,提供协议说明和手动注册按钮 - 应用支持单实例运行,后续协议调用将转发至现有实例 - 更新构建配置以注册 URL 协议,并添加详细的使用说明文档 - 修复 WindowManager 中可能因 settings 未初始化导致的潜在错误 --- SecScore-URL-Protocol.md | 230 +++++++++++++++++++++++ electron-builder.yml | 5 + eslint.config.mjs | 3 +- src/main/index.ts | 185 ++++++++++++++++++ src/main/services/TrayService.ts | 28 ++- src/main/services/WindowManager.ts | 12 +- src/preload/index.ts | 4 +- src/preload/types.ts | 2 + src/renderer/src/components/Settings.tsx | 58 ++++++ 9 files changed, 519 insertions(+), 8 deletions(-) create mode 100644 SecScore-URL-Protocol.md diff --git a/SecScore-URL-Protocol.md b/SecScore-URL-Protocol.md new file mode 100644 index 0000000..426d0fa --- /dev/null +++ b/SecScore-URL-Protocol.md @@ -0,0 +1,230 @@ +# SecScore URL 协议说明 + +## 1. 概述 + +SecScore 提供自定义 URL 协议,用于从浏览器、命令行或其他应用中直接调用软件功能。 + +- 协议名(scheme):`secscore` +- URL 基本格式: + + ```text + secscore://[/] + ``` + +- 大小写约定: + - 协议名、命令部分大小写不敏感(`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 解析函数中增加相应分支逻辑即可。 + diff --git a/electron-builder.yml b/electron-builder.yml index 00bc25c..8d4e65d 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -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/*' diff --git a/eslint.config.mjs b/eslint.config.mjs index bab70ff..69fa8eb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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 diff --git a/src/main/index.ts b/src/main/index.ts index eb78310..c20b488 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 diff --git a/src/main/services/TrayService.ts b/src/main/services/TrayService.ts index c732695..ed53140 100644 --- a/src/main/services/TrayService.ts +++ b/src/main/services/TrayService.ts @@ -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() } diff --git a/src/main/services/WindowManager.ts b/src/main/services/WindowManager.ts index 8e68065..b626616 100644 --- a/src/main/services/WindowManager.ts +++ b/src/main/services/WindowManager.ts @@ -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() diff --git a/src/preload/index.ts b/src/preload/index.ts index d169f51..ecd5836 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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) { diff --git a/src/preload/types.ts b/src/preload/types.ts index a456e31..ebeedc5 100644 --- a/src/preload/types.ts +++ b/src/preload/types.ts @@ -150,4 +150,6 @@ export interface electronApi { message: string meta?: any }) => Promise> + + registerUrlProtocol: () => Promise> } diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index 075bcbe..03a79fb 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -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 + + +
+ URL 协议 (secscore://) +
+ +
+ 可以通过 URL 链接唤起 SecScore 并执行操作,例如: +
+
+
secscore://settings
+
secscore://score
+
secscore://sidebar/toggle
+
+ + + + +
+ 需要安装版 SecScore,开发模式下可能无效。 +
+
+
+
SecScore