From ab2f4d1cecfb63ef7dea483308c71c964d8075b0 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 9 Sep 2026 19:30:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E4=BF=AE=E6=94=B9=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E9=A1=B5=20+=20=E9=A6=96=E6=AC=A1=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=E6=94=B9=E5=AF=86=EF=BC=9B=E8=87=AA=E7=BB=98?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E6=A0=8F=E4=B8=8E=E7=AE=A1=E7=90=86=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=20Electron=20=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- console/README.md | 9 +- web/src/admin/App.vue | 5 + web/src/admin/api/client.ts | 8 + web/src/admin/api/mock.ts | 1 + web/src/admin/components/DesktopTitleBar.vue | 2 +- web/src/admin/layouts/AdminLayout.vue | 13 +- web/src/admin/pages/ChangePasswordPage.vue | 150 +++++++++++++++++++ web/src/admin/pages/LoginPage.vue | 3 +- web/src/admin/router.ts | 6 + web/src/shared/api.ts | 6 + web/src/shared/types.ts | 2 + 11 files changed, 197 insertions(+), 8 deletions(-) create mode 100644 web/src/admin/pages/ChangePasswordPage.vue diff --git a/console/README.md b/console/README.md index 9095f72..a2d3a3a 100644 --- a/console/README.md +++ b/console/README.md @@ -23,10 +23,11 @@ console/ │ ├── log-bus.js # 日志环形缓冲 + 落文件 + 广播 │ └── tray-icon.js # 托盘图标(base64 内嵌,无外部资源文件) └── renderer/ - ├── index.html # 管理窗口 + ├── index.html # 管理窗口(含自绘标题栏) ├── styles.css ├── app.js - └── preload.js # contextBridge 白名单 + ├── preload.js # 管理窗口的 contextBridge 白名单 + └── shell-preload.js # 服务端管理面板窗口的 window.desktopShell 白名单 ``` ## 运行方式 @@ -72,6 +73,8 @@ scoreboard-windows-amd64/ | 服务端就绪 | 首次 `/api/health` 通过后开大屏窗口;等待超过 `display.waitForServerMs` 也先开,避免现场只见桌面 | | 服务端崩溃 | 按 `restartDelayMs` 递增退避自动重启(`maxRestarts=0` 为无限);恢复健康后自动重载大屏 | | 关管理窗口 | 只隐藏到托盘,服务端继续运行;退出必须走托盘「退出」 | +| 窗口外观 | 管理窗口与服务端管理面板窗口都是 `frame: false` 无边框,标题栏与最小化/最大化/关闭三键由页面自绘(拖拽区 `-webkit-app-region: drag`,按钮 `no-drag`,双击标题栏切最大化,关闭键 hover 变红) | +| 服务端管理面板 | 用独立无边框 Electron 窗口加载 `http://127.0.0.1:/admin`(`shell-preload.js` 暴露 `window.desktopShell`),不再调系统浏览器;面板页在浏览器里直接打开时标题栏自动不渲染 | | 日志 | 内存环形缓冲(`log.maxLines`)+ 文件 `logs/console.log`(超 `maxFileBytes` 轮转一份 `.1`) | ## 配置项(`config.json`) @@ -94,7 +97,7 @@ scoreboard-windows-amd64/ | `display.reloadEveryHours` | `24` | 定时重载兜底,0 = 关闭 | | `display.waitForServerMs` | `20000` | 服务端未就绪时开大屏的兜底等待 | | `display.backgroundColor` | `#0b0f1a` | 窗口底色,减少加载白闪 | -| `admin.width` / `admin.height` | `1040` / `760` | 管理窗口初始尺寸 | +| `admin.width` / `admin.height` | `1040` / `760` | 管理窗口初始尺寸(服务端管理面板窗口按 `width` 不小于 1100 取用) | | `log.maxLines` / `log.file` / `log.maxFileBytes` | `2000` / `logs/console.log` / `4194304` | 日志缓冲与文件 | 配置缺项自动用内置默认值,文件不存在或 JSON 语法错误时回落到全默认值,并在管理窗口顶部给出提示。 diff --git a/web/src/admin/App.vue b/web/src/admin/App.vue index c362e7c..6632c8c 100644 --- a/web/src/admin/App.vue +++ b/web/src/admin/App.vue @@ -21,6 +21,7 @@ const isDesktopShell = typeof window !== 'undefined' && Boolean(window.desktopSh display: contents; } .is-desktop-shell { + --desktop-titlebar-h: 50px; display: flex; flex-direction: column; height: 100dvh; @@ -40,4 +41,8 @@ const isDesktopShell = typeof window !== 'undefined' && Boolean(window.desktopSh .is-desktop-shell .admin-root-body :deep(.login-page) { min-height: 100%; } +/* 窄窗时 AdminLayout 把侧栏切成 fixed 全屏抽屉,得让它从标题栏下方开始 */ +.is-desktop-shell .admin-root-body :deep(.mx-sidebar) { + top: var(--desktop-titlebar-h); +} diff --git a/web/src/admin/api/client.ts b/web/src/admin/api/client.ts index c9bfd52..82b868a 100644 --- a/web/src/admin/api/client.ts +++ b/web/src/admin/api/client.ts @@ -154,6 +154,14 @@ export const adminApi = { }, ), + changePassword: (currentPassword: string, newPassword: string): Promise<{ relogin: boolean }> => + call( + () => client.auth.changePassword(currentPassword, newPassword), + () => { + throw new ApiError('INTERNAL', '示例数据模式下无法修改密码', 0) + }, + ), + health: (): Promise => call( () => client.health(), diff --git a/web/src/admin/api/mock.ts b/web/src/admin/api/mock.ts index b5250c5..1c30c0d 100644 --- a/web/src/admin/api/mock.ts +++ b/web/src/admin/api/mock.ts @@ -715,6 +715,7 @@ class MockDb { username, displayName: username === '' ? '示例用户' : username, role: 'admin', + mustChangePassword: false, lastLoginAt: null, } } diff --git a/web/src/admin/components/DesktopTitleBar.vue b/web/src/admin/components/DesktopTitleBar.vue index 1bf128e..9c8cff7 100644 --- a/web/src/admin/components/DesktopTitleBar.vue +++ b/web/src/admin/components/DesktopTitleBar.vue @@ -74,7 +74,7 @@ function handleDoubleClick(event: MouseEvent): void { align-items: center; justify-content: space-between; gap: 16px; - height: 50px; + height: var(--desktop-titlebar-h, 50px); flex: 0 0 auto; padding: 0 8px 0 16px; border-bottom: 1px solid var(--mx-divider); diff --git a/web/src/admin/layouts/AdminLayout.vue b/web/src/admin/layouts/AdminLayout.vue index 7e49a53..a689ebf 100644 --- a/web/src/admin/layouts/AdminLayout.vue +++ b/web/src/admin/layouts/AdminLayout.vue @@ -47,7 +47,10 @@ const NAV: NavGroup[] = [ { name: 'logs', label: '日志', icon: 'logs' }, ], }, - { section: '系统', items: [{ name: 'system', label: '系统设置', icon: 'system' }] }, + { section: '系统', items: [ + { name: 'system', label: '系统设置', icon: 'system' }, + { name: 'password', label: '修改密码', icon: 'lock' }, + ] }, ] const route = useRoute() @@ -87,9 +90,13 @@ async function logout(): Promise { void router.push({ name: 'dashboard' }) } -onMounted(() => { - void auth.load() +onMounted(async () => { + await auth.load() void data.load() + // 首次登录(或密码被管理员重置)必须先改密码 + if (auth.user?.mustChangePassword && route.name !== 'password') { + void router.push({ name: 'password' }) + } }) diff --git a/web/src/admin/pages/ChangePasswordPage.vue b/web/src/admin/pages/ChangePasswordPage.vue new file mode 100644 index 0000000..9802f84 --- /dev/null +++ b/web/src/admin/pages/ChangePasswordPage.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/web/src/admin/pages/LoginPage.vue b/web/src/admin/pages/LoginPage.vue index 94e5d32..af72d3f 100644 --- a/web/src/admin/pages/LoginPage.vue +++ b/web/src/admin/pages/LoginPage.vue @@ -26,7 +26,8 @@ async function submit(): Promise { } toast.success(`欢迎回来,${auth.displayName}`) await data.load() - void router.push({ name: 'entry' }) + // 首次登录强制改密,改完才放行到业务页面 + void router.push({ name: auth.user?.mustChangePassword ? 'password' : 'entry' }) } function asGuest(): void { diff --git a/web/src/admin/router.ts b/web/src/admin/router.ts index 192858b..b2cc225 100644 --- a/web/src/admin/router.ts +++ b/web/src/admin/router.ts @@ -88,6 +88,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('./pages/PlaceholderPage.vue'), meta: { title: '系统', note: '用户管理 / 备份恢复 / 赛事信息' }, }, + { + path: 'password', + name: 'password', + component: () => import('./pages/ChangePasswordPage.vue'), + meta: { title: '修改密码' }, + }, ], }, { path: '/:pathMatch(.*)*', redirect: { name: 'dashboard' } }, diff --git a/web/src/shared/api.ts b/web/src/shared/api.ts index 51d8c98..97f8748 100644 --- a/web/src/shared/api.ts +++ b/web/src/shared/api.ts @@ -115,6 +115,7 @@ export interface CrudApi { export interface AuthApi { login(username: string, password: string): Promise + changePassword(currentPassword: string, newPassword: string): Promise<{ relogin: boolean }> logout(): Promise me(): Promise } @@ -349,6 +350,11 @@ export class ApiClient { this.auth = { login: (username, password) => http.request('/auth/login', { method: 'POST', body: { username, password } }), + changePassword: (currentPassword, newPassword) => + http.request<{ relogin: boolean }>('/auth/password', { + method: 'POST', + body: { currentPassword, newPassword }, + }), logout: () => http.requestVoid('/auth/logout', { method: 'POST' }), me: () => http.request('/auth/me'), } diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index fa2f442..7b9cab1 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -383,6 +383,8 @@ export interface CurrentUser { username: string displayName: string role: string + /** 首次登录或管理员重置密码后为 true,前端应强制引导改密 */ + mustChangePassword: boolean lastLoginAt: string | null }