feat(server): 新增修改密码接口 POST /api/auth/password(验原密码、作废会话)
This commit is contained in:
1 parent
ad2322d558
commit
4cbf5a74f0
13 files changed
+528
-20
No files matched your search
+108
-4
@@ -30,6 +30,7 @@ function main() {
|
|||||||
let service = null
|
let service = null
|
||||||
let displays = null
|
let displays = null
|
||||||
let adminWindow = null
|
let adminWindow = null
|
||||||
|
let serverAdminWindow = null
|
||||||
let tray = null
|
let tray = null
|
||||||
let powerBlockerId = null
|
let powerBlockerId = null
|
||||||
let displayFallbackTimer = null
|
let displayFallbackTimer = null
|
||||||
@@ -90,6 +91,9 @@ function main() {
|
|||||||
title: 'Scoreboard Console · 启动管理',
|
title: 'Scoreboard Console · 启动管理',
|
||||||
backgroundColor: '#0f1420',
|
backgroundColor: '#0f1420',
|
||||||
show: false,
|
show: false,
|
||||||
|
// 标题栏由页面自绘;titleBarStyle 只是兜底,防止某些平台忽略 frame:false
|
||||||
|
frame: false,
|
||||||
|
titleBarStyle: 'hidden',
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, '..', 'renderer', 'preload.js'),
|
preload: path.join(__dirname, '..', 'renderer', 'preload.js'),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
@@ -98,6 +102,7 @@ function main() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
bindWindowStateEvents(adminWindow)
|
||||||
adminWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))
|
adminWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))
|
||||||
adminWindow.once('ready-to-show', () => adminWindow.show())
|
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() {
|
function ensureDisplays() {
|
||||||
if (displaysEnabled || !displays) return
|
if (displaysEnabled || !displays) return
|
||||||
displaysEnabled = true
|
displaysEnabled = true
|
||||||
@@ -138,10 +216,7 @@ function main() {
|
|||||||
{ label: '重启服务', enabled: busy, click: () => service.restart() },
|
{ label: '重启服务', enabled: busy, click: () => service.restart() },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ label: '打开管理端', click: () => showAdmin() },
|
{ label: '打开管理端', click: () => showAdmin() },
|
||||||
{
|
{ label: '打开服务端管理面板', click: () => showServerAdmin() },
|
||||||
label: '打开服务端管理面板',
|
|
||||||
click: () => shell.openExternal(status.adminUrl || `http://127.0.0.1:${cfg.values.server.port}/admin`)
|
|
||||||
},
|
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: '退出',
|
label: '退出',
|
||||||
@@ -202,6 +277,35 @@ function main() {
|
|||||||
showAdmin()
|
showAdmin()
|
||||||
return true
|
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())
|
app.on('second-instance', () => showAdmin())
|
||||||
|
|||||||
+29
-8
@@ -20,7 +20,11 @@ const el = {
|
|||||||
btnRestart: document.getElementById('btn-restart'),
|
btnRestart: document.getElementById('btn-restart'),
|
||||||
btnRelayout: document.getElementById('btn-relayout'),
|
btnRelayout: document.getElementById('btn-relayout'),
|
||||||
btnOpenAdmin: document.getElementById('btn-open-admin'),
|
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 = {
|
const DOT_CLASS = {
|
||||||
@@ -31,10 +35,7 @@ const DOT_CLASS = {
|
|||||||
stopped: ''
|
stopped: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastStatus = null
|
|
||||||
|
|
||||||
function renderStatus(status) {
|
function renderStatus(status) {
|
||||||
lastStatus = status
|
|
||||||
el.version.textContent = status.appVersion ? `v${status.appVersion}` : ''
|
el.version.textContent = status.appVersion ? `v${status.appVersion}` : ''
|
||||||
|
|
||||||
el.stateDot.className = `dot ${status.healthy ? 'ok' : DOT_CLASS[status.state] || ''}`
|
el.stateDot.className = `dot ${status.healthy ? 'ok' : DOT_CLASS[status.state] || ''}`
|
||||||
@@ -104,6 +105,28 @@ async function refresh() {
|
|||||||
renderStatus(await window.consoleApi.getStatus())
|
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() {
|
function bindActions() {
|
||||||
el.btnStart.addEventListener('click', async () => renderStatus(await window.consoleApi.startService()))
|
el.btnStart.addEventListener('click', async () => renderStatus(await window.consoleApi.startService()))
|
||||||
el.btnStop.addEventListener('click', async () => renderStatus(await window.consoleApi.stopService()))
|
el.btnStop.addEventListener('click', async () => renderStatus(await window.consoleApi.stopService()))
|
||||||
@@ -113,14 +136,12 @@ function bindActions() {
|
|||||||
await window.consoleApi.clearLog()
|
await window.consoleApi.clearLog()
|
||||||
setLog([])
|
setLog([])
|
||||||
})
|
})
|
||||||
el.btnOpenAdmin.addEventListener('click', () => {
|
el.btnOpenAdmin.addEventListener('click', () => window.consoleApi.openServerAdmin())
|
||||||
const url = (lastStatus && lastStatus.adminUrl) || 'http://127.0.0.1:8080/admin'
|
|
||||||
window.consoleApi.openExternal(url)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function boot() {
|
async function boot() {
|
||||||
bindActions()
|
bindActions()
|
||||||
|
bindWindowControls()
|
||||||
window.consoleApi.onStatus(renderStatus)
|
window.consoleApi.onStatus(renderStatus)
|
||||||
window.consoleApi.onLog(appendLog)
|
window.consoleApi.onLog(appendLog)
|
||||||
setLog(await window.consoleApi.getLog())
|
setLog(await window.consoleApi.getLog())
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<!-- 窗口是无边框的,标题栏和最小化/最大化/关闭三键都在这条上自绘 -->
|
||||||
|
<header id="titlebar" class="topbar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<span class="brand-mark"></span>
|
<span class="brand-mark"></span>
|
||||||
<div>
|
<div>
|
||||||
@@ -20,6 +21,27 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
<span id="app-version" class="muted"></span>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ contextBridge.exposeInMainWorld('consoleApi', {
|
|||||||
clearLog: () => ipcRenderer.invoke('log:clear'),
|
clearLog: () => ipcRenderer.invoke('log:clear'),
|
||||||
relayoutDisplays: () => ipcRenderer.invoke('display:relayout'),
|
relayoutDisplays: () => ipcRenderer.invoke('display:relayout'),
|
||||||
openExternal: (url) => ipcRenderer.invoke('app:open-external', url),
|
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),
|
onLog: (callback) => subscribe('log:line', callback),
|
||||||
onStatus: (callback) => subscribe('service:status', callback)
|
onStatus: (callback) => subscribe('service:status', callback)
|
||||||
})
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -33,9 +33,15 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
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);
|
border-bottom: 1px solid var(--border);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
|
/* 整条标题栏拖拽,按钮区单独开洞,否则按钮点不动 */
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
@@ -45,25 +51,99 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark {
|
.brand-mark {
|
||||||
width: 10px;
|
width: 8px;
|
||||||
height: 34px;
|
height: 22px;
|
||||||
border-radius: 5px;
|
border-radius: 4px;
|
||||||
background: linear-gradient(180deg, var(--accent), #1b6bb8);
|
background: linear-gradient(180deg, var(--accent), #1b6bb8);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-sub {
|
.brand-sub {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
|
line-height: 1.3;
|
||||||
color: var(--muted);
|
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 {
|
main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@@ -202,6 +202,19 @@ func (s *Service) SetPassword(ctx context.Context, username, password string, mu
|
|||||||
return s.RevokeUserSessions(ctx, id)
|
return s.RevokeUserSessions(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VerifyPassword 校验指定用户的原密码,不创建会话(供「修改密码」等场景使用)。
|
||||||
|
func (s *Service) VerifyPassword(ctx context.Context, userID int64, password string) error {
|
||||||
|
var hash string
|
||||||
|
if err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT password_hash FROM users WHERE id = ? AND active = 1`, userID).Scan(&hash); err != nil {
|
||||||
|
return ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
|
||||||
|
return ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Authenticate 校验用户名密码并创建会话,返回用户与不落库的明文 token。
|
// Authenticate 校验用户名密码并创建会话,返回用户与不落库的明文 token。
|
||||||
func (s *Service) Authenticate(ctx context.Context, username, password, ip, userAgent string) (*User, string, error) {
|
func (s *Service) Authenticate(ctx context.Context, username, password, ip, userAgent string) (*User, string, error) {
|
||||||
if !s.AllowLogin(ip) {
|
if !s.AllowLogin(ip) {
|
||||||
|
|||||||
@@ -63,6 +63,43 @@ func (s *server) me(w http.ResponseWriter, r *http.Request) (any, error) {
|
|||||||
return userDTO(u), nil
|
return userDTO(u), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type changePasswordInput struct {
|
||||||
|
CurrentPassword string `json:"currentPassword"`
|
||||||
|
NewPassword string `json:"newPassword"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// changePassword 修改当前登录用户的密码:先验原密码,再改密并作废全部会话。
|
||||||
|
func (s *server) changePassword(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||||
|
u := middleware.User(r.Context())
|
||||||
|
if u.Role == auth.RoleGuest {
|
||||||
|
return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_REQUIRED", message: "请先登录"}
|
||||||
|
}
|
||||||
|
|
||||||
|
var in changePasswordInput
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(in.NewPassword) < 8 {
|
||||||
|
return nil, badRequest("新密码至少 8 位", nil)
|
||||||
|
}
|
||||||
|
if in.NewPassword == in.CurrentPassword {
|
||||||
|
return nil, badRequest("新密码不能与原密码相同", nil)
|
||||||
|
}
|
||||||
|
// 会话被盗后不能直接改密,必须再验一次原密码
|
||||||
|
if err := s.auth.VerifyPassword(r.Context(), u.ID, in.CurrentPassword); err != nil {
|
||||||
|
return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_FAILED", message: "原密码不正确"}
|
||||||
|
}
|
||||||
|
if err := s.auth.SetPassword(r.Context(), u.Username, in.NewPassword, false); err != nil {
|
||||||
|
return nil, internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPassword 已作废全部会话,这里清掉 Cookie 让前端引导重新登录
|
||||||
|
s.auth.ClearSessionCookie(w)
|
||||||
|
s.auth.ClearCSRFCookie(w)
|
||||||
|
s.audit(r.Context(), "auth.change_password", "user", u.ID, nil, map[string]any{"username": u.Username})
|
||||||
|
return map[string]any{"relogin": true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func userDTO(u *auth.User) currentUserDTO {
|
func userDTO(u *auth.User) currentUserDTO {
|
||||||
dto := currentUserDTO{
|
dto := currentUserDTO{
|
||||||
ID: u.ID,
|
ID: u.ID,
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ func (s *server) routes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("POST /api/auth/login", s.handle(s.login))
|
mux.HandleFunc("POST /api/auth/login", s.handle(s.login))
|
||||||
mux.HandleFunc("POST /api/auth/logout", s.handle(s.logout))
|
mux.HandleFunc("POST /api/auth/logout", s.handle(s.logout))
|
||||||
mux.HandleFunc("GET /api/auth/me", s.handle(s.me))
|
mux.HandleFunc("GET /api/auth/me", s.handle(s.me))
|
||||||
|
mux.HandleFunc("POST /api/auth/password", s.handle(s.changePassword))
|
||||||
|
|
||||||
// 系统配置(guest 一律 403)
|
// 系统配置(guest 一律 403)
|
||||||
mux.HandleFunc("GET /api/settings", s.read(auth.RoleAdmin, s.getSettingsHandler))
|
mux.HandleFunc("GET /api/settings", s.read(auth.RoleAdmin, s.getSettingsHandler))
|
||||||
|
|||||||
+37
-1
@@ -1,7 +1,43 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
|
import DesktopTitleBar from './components/DesktopTitleBar.vue'
|
||||||
|
|
||||||
|
// 浏览器里没有 desktopShell,包装层必须完全透明,否则会改变原有 Web 布局
|
||||||
|
const isDesktopShell = typeof window !== 'undefined' && Boolean(window.desktopShell)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterView />
|
<div class="admin-root" :class="{ 'is-desktop-shell': isDesktopShell }">
|
||||||
|
<DesktopTitleBar />
|
||||||
|
<div class="admin-root-body">
|
||||||
|
<RouterView />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-root,
|
||||||
|
.admin-root-body {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
.is-desktop-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.is-desktop-shell .admin-root-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
/* 页面自己写死 100dvh,桌面壳里要让它填满标题栏下方的剩余高度 */
|
||||||
|
.is-desktop-shell .admin-root-body :deep(.admin-shell) {
|
||||||
|
height: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.is-desktop-shell .admin-root-body :deep(.login-page) {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import MxIcon from './MxIcon.vue'
|
||||||
|
|
||||||
|
// preload 先于页面脚本执行,这里读到的值在组件生命周期内不会变;
|
||||||
|
// 浏览器里访问 /admin 时没有 desktopShell,整个组件不渲染
|
||||||
|
const shell = window.desktopShell
|
||||||
|
const visible = Boolean(shell)
|
||||||
|
const maximized = ref(false)
|
||||||
|
let unsubscribe: (() => void) | undefined
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!shell) return
|
||||||
|
void shell.isMaximized().then((value) => {
|
||||||
|
maximized.value = value
|
||||||
|
})
|
||||||
|
unsubscribe = shell.onMaximizeChange((value) => {
|
||||||
|
maximized.value = value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
unsubscribe?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleMaximize(): void {
|
||||||
|
shell?.maximize()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDoubleClick(event: MouseEvent): void {
|
||||||
|
// 双击落在按钮上不切最大化,否则连点按钮会顺带把窗口切走
|
||||||
|
if (event.target instanceof Element && event.target.closest('button')) return
|
||||||
|
toggleMaximize()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header v-if="visible" class="desktop-titlebar" @dblclick="handleDoubleClick">
|
||||||
|
<div class="desktop-titlebar-brand">
|
||||||
|
<span class="desktop-titlebar-mark" aria-hidden="true"></span>
|
||||||
|
<span class="desktop-titlebar-title">积分榜管理</span>
|
||||||
|
<span class="desktop-titlebar-sub">服务端管理面板</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="desktop-titlebar-controls">
|
||||||
|
<button class="desktop-win-btn" type="button" title="最小化" aria-label="最小化" @click="shell?.minimize()">
|
||||||
|
<MxIcon name="minimize" :size="16" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="desktop-win-btn"
|
||||||
|
type="button"
|
||||||
|
:title="maximized ? '还原' : '最大化'"
|
||||||
|
:aria-label="maximized ? '还原' : '最大化'"
|
||||||
|
@click="toggleMaximize"
|
||||||
|
>
|
||||||
|
<MxIcon :name="maximized ? 'restore' : 'maximize'" :size="16" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="desktop-win-btn desktop-win-btn-close"
|
||||||
|
type="button"
|
||||||
|
title="关闭"
|
||||||
|
aria-label="关闭"
|
||||||
|
@click="shell?.close()"
|
||||||
|
>
|
||||||
|
<MxIcon name="close" :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.desktop-titlebar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
height: 50px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0 8px 0 16px;
|
||||||
|
border-bottom: 1px solid var(--mx-divider);
|
||||||
|
background: var(--mx-s1);
|
||||||
|
color: var(--mx-text);
|
||||||
|
user-select: none;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
.desktop-titlebar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.desktop-titlebar-mark {
|
||||||
|
width: 8px;
|
||||||
|
height: 22px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: linear-gradient(180deg, var(--mx-primary), var(--mx-primary-active));
|
||||||
|
}
|
||||||
|
.desktop-titlebar-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.desktop-titlebar-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.desktop-titlebar-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
.desktop-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(--mx-t3);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background var(--mx-d1) var(--mx-ease),
|
||||||
|
color var(--mx-d1) var(--mx-ease),
|
||||||
|
border-color var(--mx-d1) var(--mx-ease);
|
||||||
|
}
|
||||||
|
.desktop-win-btn:hover {
|
||||||
|
background: var(--mx-s-hover);
|
||||||
|
color: var(--mx-text);
|
||||||
|
}
|
||||||
|
.desktop-win-btn:active {
|
||||||
|
background: var(--mx-s-active);
|
||||||
|
}
|
||||||
|
.desktop-win-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--mx-primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
/* 关闭键是唯一的破坏性操作,hover 变红做区分 */
|
||||||
|
.desktop-win-btn-close:hover {
|
||||||
|
border-color: var(--mx-error);
|
||||||
|
background: var(--mx-error);
|
||||||
|
color: var(--mx-error-on);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -20,6 +20,9 @@ export const ICON_PATHS = {
|
|||||||
bell: 'M6 9a6 6 0 1 1 12 0c0 5 2 6 2 6H4s2-1 2-6ZM10 19a2 2 0 0 0 4 0',
|
bell: 'M6 9a6 6 0 1 1 12 0c0 5 2 6 2 6H4s2-1 2-6ZM10 19a2 2 0 0 0 4 0',
|
||||||
push: 'M12 20V6M8 10l4-4 4 4M4 20h16',
|
push: 'M12 20V6M8 10l4-4 4 4M4 20h16',
|
||||||
close: 'M6 6l12 12M18 6 6 18',
|
close: 'M6 6l12 12M18 6 6 18',
|
||||||
|
minimize: 'M5 12h14',
|
||||||
|
maximize: 'M5 5h14v14H5z',
|
||||||
|
restore: 'M8 8h11v11H8zM5 16V5h11',
|
||||||
check: 'M5 12.5 10 17l9-10',
|
check: 'M5 12.5 10 17l9-10',
|
||||||
warning: 'M12 4l9 16H3l9-16Zm0 6v4m0 3v.5',
|
warning: 'M12 4l9 16H3l9-16Zm0 6v4m0 3v.5',
|
||||||
search: 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm10 2-4.3-4.3',
|
search: 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm10 2-4.3-4.3',
|
||||||
|
|||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Console 的无边框窗口会在 preload 里注入 window.desktopShell。
|
||||||
|
* 浏览器直接访问 /admin 时它不存在,DesktopTitleBar 据此自动不渲染。
|
||||||
|
*/
|
||||||
|
export interface DesktopShellApi {
|
||||||
|
platform: string
|
||||||
|
minimize: () => void
|
||||||
|
maximize: () => void
|
||||||
|
close: () => void
|
||||||
|
isMaximized: () => Promise<boolean>
|
||||||
|
onMaximizeChange: (callback: (maximized: boolean) => void) => () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
desktopShell?: DesktopShellApi
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user