mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +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
|
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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 }>>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user