From 4cbf5a74f0d956fcf73f6a77a167a0fb99b05004 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 9 Sep 2026 19:26:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E6=96=B0=E5=A2=9E=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=E5=AF=86=E7=A0=81=E6=8E=A5=E5=8F=A3=20POST=20/api/aut?= =?UTF-8?q?h/password=EF=BC=88=E9=AA=8C=E5=8E=9F=E5=AF=86=E7=A0=81?= =?UTF-8?q?=E3=80=81=E4=BD=9C=E5=BA=9F=E4=BC=9A=E8=AF=9D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- console/main/index.js | 112 +++++++++++++- console/renderer/app.js | 37 ++++- console/renderer/index.html | 24 ++- console/renderer/preload.js | 6 + console/renderer/shell-preload.js | 18 +++ console/renderer/styles.css | 92 +++++++++++- server/internal/auth/auth.go | 13 ++ server/internal/httpapi/handlers_config.go | 37 +++++ server/internal/httpapi/server.go | 1 + web/src/admin/App.vue | 38 ++++- web/src/admin/components/DesktopTitleBar.vue | 149 +++++++++++++++++++ web/src/admin/components/icons.ts | 3 + web/src/admin/types/desktop-shell.d.ts | 18 +++ 13 files changed, 528 insertions(+), 20 deletions(-) create mode 100644 console/renderer/shell-preload.js create mode 100644 web/src/admin/components/DesktopTitleBar.vue create mode 100644 web/src/admin/types/desktop-shell.d.ts diff --git a/console/main/index.js b/console/main/index.js index ec7eb22..c7ba27b 100644 --- a/console/main/index.js +++ b/console/main/index.js @@ -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()) diff --git a/console/renderer/app.js b/console/renderer/app.js index a83622b..aa953f1 100644 --- a/console/renderer/app.js +++ b/console/renderer/app.js @@ -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()) diff --git a/console/renderer/index.html b/console/renderer/index.html index 7ed94d4..23add5d 100644 --- a/console/renderer/index.html +++ b/console/renderer/index.html @@ -10,7 +10,8 @@ -
+ +
@@ -20,6 +21,27 @@
+
+ + + +
diff --git a/console/renderer/preload.js b/console/renderer/preload.js index c3653d6..f23d7de 100644 --- a/console/renderer/preload.js +++ b/console/renderer/preload.js @@ -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) }) diff --git a/console/renderer/shell-preload.js b/console/renderer/shell-preload.js new file mode 100644 index 0000000..c24da0b --- /dev/null +++ b/console/renderer/shell-preload.js @@ -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) + } +}) diff --git a/console/renderer/styles.css b/console/renderer/styles.css index d70fab4..724c691 100644 --- a/console/renderer/styles.css +++ b/console/renderer/styles.css @@ -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; diff --git a/server/internal/auth/auth.go b/server/internal/auth/auth.go index 3eae201..d8b3925 100644 --- a/server/internal/auth/auth.go +++ b/server/internal/auth/auth.go @@ -202,6 +202,19 @@ func (s *Service) SetPassword(ctx context.Context, username, password string, mu 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。 func (s *Service) Authenticate(ctx context.Context, username, password, ip, userAgent string) (*User, string, error) { if !s.AllowLogin(ip) { diff --git a/server/internal/httpapi/handlers_config.go b/server/internal/httpapi/handlers_config.go index ca16a88..d8db4c3 100644 --- a/server/internal/httpapi/handlers_config.go +++ b/server/internal/httpapi/handlers_config.go @@ -63,6 +63,43 @@ func (s *server) me(w http.ResponseWriter, r *http.Request) (any, error) { 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 { dto := currentUserDTO{ ID: u.ID, diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 46a196b..05cab07 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -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/logout", s.handle(s.logout)) mux.HandleFunc("GET /api/auth/me", s.handle(s.me)) + mux.HandleFunc("POST /api/auth/password", s.handle(s.changePassword)) // 系统配置(guest 一律 403) mux.HandleFunc("GET /api/settings", s.read(auth.RoleAdmin, s.getSettingsHandler)) diff --git a/web/src/admin/App.vue b/web/src/admin/App.vue index a78fb6f..c362e7c 100644 --- a/web/src/admin/App.vue +++ b/web/src/admin/App.vue @@ -1,7 +1,43 @@ + + diff --git a/web/src/admin/components/DesktopTitleBar.vue b/web/src/admin/components/DesktopTitleBar.vue new file mode 100644 index 0000000..1bf128e --- /dev/null +++ b/web/src/admin/components/DesktopTitleBar.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/web/src/admin/components/icons.ts b/web/src/admin/components/icons.ts index b5c227d..fba2c62 100644 --- a/web/src/admin/components/icons.ts +++ b/web/src/admin/components/icons.ts @@ -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', push: 'M12 20V6M8 10l4-4 4 4M4 20h16', close: 'M6 6l12 12M18 6 6 18', + minimize: 'M5 12h14', + maximize: 'M5 5h14v14H5z', + restore: 'M8 8h11v11H8zM5 16V5h11', check: 'M5 12.5 10 17l9-10', 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', diff --git a/web/src/admin/types/desktop-shell.d.ts b/web/src/admin/types/desktop-shell.d.ts new file mode 100644 index 0000000..f0640ce --- /dev/null +++ b/web/src/admin/types/desktop-shell.d.ts @@ -0,0 +1,18 @@ +/** + * Console 的无边框窗口会在 preload 里注入 window.desktopShell。 + * 浏览器直接访问 /admin 时它不存在,DesktopTitleBar 据此自动不渲染。 + */ +export interface DesktopShellApi { + platform: string + minimize: () => void + maximize: () => void + close: () => void + isMaximized: () => Promise + onMaximizeChange: (callback: (maximized: boolean) => void) => () => void +} + +declare global { + interface Window { + desktopShell?: DesktopShellApi + } +}