mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-21 11:49:02 +08:00
feat: 添加自定义 URL 协议支持及悬浮侧边栏显隐控制
- 新增 `secscore://` 自定义 URL 协议,支持通过链接直接导航至应用内页面(如 `secscore://settings`)和控制悬浮侧边栏(如 `secscore://sidebar/toggle`) - 在系统托盘菜单中添加“显隐悬浮侧边栏”选项 - 在设置页面添加“URL 链接”选项卡,提供协议说明和手动注册按钮 - 应用支持单实例运行,后续协议调用将转发至现有实例 - 更新构建配置以注册 URL 协议,并添加详细的使用说明文档 - 修复 WindowManager 中可能因 settings 未初始化导致的潜在错误
This commit is contained in:
@@ -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