feat(server): 新增修改密码接口 POST /api/auth/password(验原密码、作废会话)

This commit is contained in:
AskaEth committed 2026-09-09 19:26:35 +08:00
1 parent ad2322d558
commit 4cbf5a74f0
13 files changed
+528 -20

No files matched your search

+108 -4
View File
@@ -30,6 +30,7 @@ function main() {
let service = null
let displays = null
let adminWindow = null
let serverAdminWindow = null
let tray = null
let powerBlockerId = null
let displayFallbackTimer = null
@@ -90,6 +91,9 @@ function main() {
title: 'Scoreboard Console · 启动管理',
backgroundColor: '#0f1420',
show: false,
// 标题栏由页面自绘;titleBarStyle 只是兜底,防止某些平台忽略 frame:false
frame: false,
titleBarStyle: 'hidden',
webPreferences: {
preload: path.join(__dirname, '..', 'renderer', 'preload.js'),
contextIsolation: true,
@@ -98,6 +102,7 @@ function main() {
}
})
bindWindowStateEvents(adminWindow)
adminWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))
adminWindow.once('ready-to-show', () => adminWindow.show())
@@ -114,6 +119,79 @@ function main() {
})
}
function adminPanelUrl() {
// 复用服务端的地址推导:host 配成 0.0.0.0 时本机也要走回环
if (service) return service.status.adminUrl
const host = (cfg.values.server.host || '127.0.0.1') === '0.0.0.0' ? '127.0.0.1' : cfg.values.server.host
return `http://${host || '127.0.0.1'}:${cfg.values.server.port}/admin`
}
function showServerAdmin() {
if (!serverAdminWindow || serverAdminWindow.isDestroyed()) {
createServerAdminWindow()
return
}
if (serverAdminWindow.isMinimized()) serverAdminWindow.restore()
serverAdminWindow.show()
serverAdminWindow.focus()
}
// 服务端管理面板是 Web 页面,用无边框 Electron 窗口承载,省得现场再开浏览器
function createServerAdminWindow() {
const { width, height } = cfg.values.admin
serverAdminWindow = new BrowserWindow({
width: Math.max(width, 1100),
height,
minWidth: 900,
minHeight: 620,
title: 'Scoreboard · 服务端管理面板',
backgroundColor: '#161616',
show: false,
frame: false,
titleBarStyle: 'hidden',
webPreferences: {
preload: path.join(__dirname, '..', 'renderer', 'shell-preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
})
bindWindowStateEvents(serverAdminWindow)
const url = adminPanelUrl()
log.push('console', `打开服务端管理面板:${url}`)
serverAdminWindow.loadURL(url).catch((err) => {
log.push('console', `服务端管理面板加载失败:${err.message}`)
})
// 加载失败时 ready-to-show 不会触发,兜底把窗口显示出来,否则用户看不到任何反馈
const reveal = () => {
if (serverAdminWindow && !serverAdminWindow.isDestroyed()) {
serverAdminWindow.show()
serverAdminWindow.focus()
}
}
serverAdminWindow.once('ready-to-show', reveal)
serverAdminWindow.webContents.on('did-fail-load', (_event, _code, _desc, _url, isMainFrame) => {
if (isMainFrame) reveal()
})
serverAdminWindow.on('closed', () => {
serverAdminWindow = null
})
}
// 渲染层要随时知道最大化状态,才能在「最大化/还原」之间换图标
function bindWindowStateEvents(win) {
const notify = () => {
if (win.isDestroyed()) return
win.webContents.send('window:maximize-changed', win.isMaximized())
}
win.on('maximize', notify)
win.on('unmaximize', notify)
}
function ensureDisplays() {
if (displaysEnabled || !displays) return
displaysEnabled = true
@@ -138,10 +216,7 @@ function main() {
{ label: '重启服务', enabled: busy, click: () => service.restart() },
{ type: 'separator' },
{ label: '打开管理端', click: () => showAdmin() },
{
label: '打开服务端管理面板',
click: () => shell.openExternal(status.adminUrl || `http://127.0.0.1:${cfg.values.server.port}/admin`)
},
{ label: '打开服务端管理面板', click: () => showServerAdmin() },
{ type: 'separator' },
{
label: '退出',
@@ -202,6 +277,35 @@ function main() {
showAdmin()
return true
})
ipcMain.handle('app:open-server-admin', () => {
showServerAdmin()
return true
})
// 窗口控制统一从 event.sender 反查窗口,多个窗口共用同一套通道
ipcMain.handle('window:minimize', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (win && !win.isDestroyed()) win.minimize()
return true
})
ipcMain.handle('window:maximize', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win || win.isDestroyed()) return false
// 以「切换」语义实现,避免按钮在已最大化时点了没反应
const next = !win.isMaximized()
if (next) win.maximize()
else win.unmaximize()
return next
})
ipcMain.handle('window:close', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (win && !win.isDestroyed()) win.close()
return true
})
ipcMain.handle('window:is-maximized', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
return Boolean(win && !win.isDestroyed() && win.isMaximized())
})
}
app.on('second-instance', () => showAdmin())
+29 -8
View File
@@ -20,7 +20,11 @@ const el = {
btnRestart: document.getElementById('btn-restart'),
btnRelayout: document.getElementById('btn-relayout'),
btnOpenAdmin: document.getElementById('btn-open-admin'),
btnClearLog: document.getElementById('btn-clear-log')
btnClearLog: document.getElementById('btn-clear-log'),
titlebar: document.getElementById('titlebar'),
winMin: document.getElementById('win-min'),
winMax: document.getElementById('win-max'),
winClose: document.getElementById('win-close')
}
const DOT_CLASS = {
@@ -31,10 +35,7 @@ const DOT_CLASS = {
stopped: ''
}
let lastStatus = null
function renderStatus(status) {
lastStatus = status
el.version.textContent = status.appVersion ? `v${status.appVersion}` : ''
el.stateDot.className = `dot ${status.healthy ? 'ok' : DOT_CLASS[status.state] || ''}`
@@ -104,6 +105,28 @@ async function refresh() {
renderStatus(await window.consoleApi.getStatus())
}
function renderMaximized(maximized) {
el.winMax.classList.toggle('is-maximized', maximized)
const label = maximized ? '还原' : '最大化'
el.winMax.title = label
el.winMax.setAttribute('aria-label', label)
}
function bindWindowControls() {
el.winMin.addEventListener('click', () => window.consoleApi.minimize())
el.winMax.addEventListener('click', () => window.consoleApi.maximize())
el.winClose.addEventListener('click', () => window.consoleApi.close())
// 双击标题栏切换最大化;点在按钮上的双击不算,否则连点会误触
el.titlebar.addEventListener('dblclick', (event) => {
if (event.target instanceof Element && event.target.closest('.win-btn')) return
window.consoleApi.maximize()
})
window.consoleApi.onMaximizeChange(renderMaximized)
window.consoleApi.isMaximized().then(renderMaximized)
}
function bindActions() {
el.btnStart.addEventListener('click', async () => renderStatus(await window.consoleApi.startService()))
el.btnStop.addEventListener('click', async () => renderStatus(await window.consoleApi.stopService()))
@@ -113,14 +136,12 @@ function bindActions() {
await window.consoleApi.clearLog()
setLog([])
})
el.btnOpenAdmin.addEventListener('click', () => {
const url = (lastStatus && lastStatus.adminUrl) || 'http://127.0.0.1:8080/admin'
window.consoleApi.openExternal(url)
})
el.btnOpenAdmin.addEventListener('click', () => window.consoleApi.openServerAdmin())
}
async function boot() {
bindActions()
bindWindowControls()
window.consoleApi.onStatus(renderStatus)
window.consoleApi.onLog(appendLog)
setLog(await window.consoleApi.getLog())
+23 -1
View File
@@ -10,7 +10,8 @@
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header class="topbar">
<!-- 窗口是无边框的,标题栏和最小化/最大化/关闭三键都在这条上自绘 -->
<header id="titlebar" class="topbar">
<div class="brand">
<span class="brand-mark"></span>
<div>
@@ -20,6 +21,27 @@
</div>
<div class="topbar-right">
<span id="app-version" class="muted"></span>
<div class="win-controls">
<button id="win-min" class="win-btn" type="button" title="最小化" aria-label="最小化">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path d="M5 12h14" />
</svg>
</button>
<button id="win-max" class="win-btn" type="button" title="最大化" aria-label="最大化">
<svg class="icon-max" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<rect x="5" y="5" width="14" height="14" rx="2" />
</svg>
<svg class="icon-restore" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<rect x="4" y="8" width="12" height="12" rx="2" />
<path d="M8 8V6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2" />
</svg>
</button>
<button id="win-close" class="win-btn win-btn-close" type="button" title="关闭" aria-label="关闭">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
</div>
</div>
</header>
+6
View File
@@ -18,6 +18,12 @@ contextBridge.exposeInMainWorld('consoleApi', {
clearLog: () => ipcRenderer.invoke('log:clear'),
relayoutDisplays: () => ipcRenderer.invoke('display:relayout'),
openExternal: (url) => ipcRenderer.invoke('app:open-external', url),
openServerAdmin: () => ipcRenderer.invoke('app:open-server-admin'),
minimize: () => ipcRenderer.invoke('window:minimize'),
maximize: () => ipcRenderer.invoke('window:maximize'),
close: () => ipcRenderer.invoke('window:close'),
isMaximized: () => ipcRenderer.invoke('window:is-maximized'),
onMaximizeChange: (callback) => subscribe('window:maximize-changed', callback),
onLog: (callback) => subscribe('log:line', callback),
onStatus: (callback) => subscribe('service:status', callback)
})
+18
View File
@@ -0,0 +1,18 @@
'use strict'
const { contextBridge, ipcRenderer } = require('electron')
// 服务端管理面板是 http://127.0.0.1 下的 Web 页面,只放行窗口控制这一组能力,
// 不把 ipcRenderer 交给页面,避免面板页面拿到主进程的任意通道
contextBridge.exposeInMainWorld('desktopShell', {
platform: process.platform,
minimize: () => ipcRenderer.invoke('window:minimize'),
maximize: () => ipcRenderer.invoke('window:maximize'),
close: () => ipcRenderer.invoke('window:close'),
isMaximized: () => ipcRenderer.invoke('window:is-maximized'),
onMaximizeChange: (callback) => {
const handler = (_event, maximized) => callback(maximized)
ipcRenderer.on('window:maximize-changed', handler)
return () => ipcRenderer.removeListener('window:maximize-changed', handler)
}
})
+86 -6
View File
@@ -33,9 +33,15 @@ body {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
gap: 16px;
height: 50px;
flex: 0 0 auto;
padding: 0 8px 0 16px;
border-bottom: 1px solid var(--border);
background: var(--panel);
/* 整条标题栏拖拽,按钮区单独开洞,否则按钮点不动 */
-webkit-app-region: drag;
user-select: none;
}
.brand {
@@ -45,25 +51,99 @@ body {
}
.brand-mark {
width: 10px;
height: 34px;
border-radius: 5px;
width: 8px;
height: 22px;
border-radius: 4px;
background: linear-gradient(180deg, var(--accent), #1b6bb8);
}
h1 {
margin: 0;
font-size: 16px;
font-size: 15px;
font-weight: 600;
line-height: 1.25;
letter-spacing: 0.02em;
}
.brand-sub {
margin: 0;
font-size: 12px;
font-size: 11px;
line-height: 1.3;
color: var(--muted);
}
.topbar-right {
display: flex;
align-items: center;
gap: 12px;
}
.win-controls {
display: flex;
align-items: center;
gap: 4px;
-webkit-app-region: no-drag;
}
.win-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 32px;
padding: 0;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--muted);
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease,
border-color 0.15s ease;
}
.win-btn svg {
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.win-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text);
}
.win-btn:active {
background: rgba(255, 255, 255, 0.14);
}
.win-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
/* 关闭键是唯一的破坏性操作,hover 变红做区分 */
.win-btn-close:hover {
border-color: var(--err);
background: var(--err);
color: #fff;
}
.win-btn .icon-restore {
display: none;
}
.win-btn.is-maximized .icon-max {
display: none;
}
.win-btn.is-maximized .icon-restore {
display: block;
}
main {
flex: 1;
min-height: 0;