feat: 人员年龄/号码布、图片上传、多显示器菜单、Console 两栏、exe 改名等一批改动

人员与报名:
- 人员唯一标识改为「证件号 或 号码布」二选一(0013/0014 迁移 + 导入匹配/回填/冲突)
- 人员新增「年龄」(0015 迁移):表单/列表/导入模板表头 11 列;冲突比较只在两边
  都有值且不同时才算差异,旧数据不误报
- 冲突决策键改为「证件号优先 → 号码布」(前端原先只用 idCard,号码布行会互相覆盖)
- 报名选择器支持无证件号但有号码布的运动员

大屏与 Console:
- 大屏控制新增「显示器」菜单:可给指定显示器重建全屏窗口;单屏/主显示器强制窗口化,
  限制在 Console 侧判定,不信任页面传参
- Console 服务管理窗口改左右两栏,运行日志独占右栏满高;窗口默认尺寸 1280×860
- 大屏右上角时钟精确到秒(同时把刷新间隔 15s 改为 1s)

图片与配置:
- 新增图片上传 POST /api/uploads(recorder+,png/jpg/webp/gif,≤5MiB)与匿名静态
  访问 GET /uploads/{name}(防目录穿越);文件落赛事项目目录 uploads/,随赛事包
  一起导出/导入
- 「赛事信息」新增赛事会标 / 大屏会标 / 大屏背景图三个上传项
- 参赛单位 logo 输入框右侧加上传按钮

关于页与构建:
- 构建时间打通:version.BuildTime + /api/system/about 返回 + build.bat 注入
  (用 PowerShell 取时间戳,避免本地化 %DATE% 的非 ASCII 星期)

安装包:
- 主程序改名 Scoreboard_Console.exe(win.executableName;productName/显示名不变)
- 记住上次安装目录:自定义 NSIS 宏优先沿用自存路径(卸载不清除),并在 customInit
  里补写回 electron-builder 的键,避免安装模式页把 $INSTDIR 打回默认

自检:服务端 12 包全绿;前端 vue-tsc + build 通过、node --test 58/58;console 12 文件通过。
This commit is contained in:
AskaEth committed 2026-09-10 09:05:06 +08:00
1 parent 24fff8dd08
commit 6483acf9ad
50 files changed
+2980 -395

No files matched your search

+6 -2
View File
@@ -34,7 +34,7 @@ console/
├── styles.css
├── app.js
├── preload.js # 管理窗口的 contextBridge 白名单
└── shell-preload.js # 服务端管理面板窗口的 window.desktopShell 白名单
└── shell-preload.js # 服务端管理面板窗口的 window.desktopShell 白名单(窗口控制 + 显示器菜单)
```
## 运行方式
@@ -56,7 +56,7 @@ pnpm start # 等价于 electron .
```
scoreboard-windows-amd64/
├── Scoreboard Console.exe
├── Scoreboard_Console.exe
├── resources/app.asar # 内含 main/ renderer/ config.json
├── config.json # exe 旁边的可编辑配置
├── scoreboard.exe
@@ -85,6 +85,7 @@ scoreboard-windows-amd64/
| 开机自启 | 默认「已注册但未启用」;在服务管理窗口的「开机自启」开关里控制(`startup.openAtLogin`),仅打包后的 Windows/macOS 生效,开发态不注册 |
| 窗口外观 | 管理窗口与服务端管理面板窗口都是 `frame: false` 无边框,标题栏与最小化/最大化/关闭三键由页面自绘(拖拽区 `-webkit-app-region: drag`,按钮 `no-drag`,双击标题栏切最大化,关闭键 hover 变红) |
| 服务端管理面板 | 用独立无边框 Electron 窗口加载 `http://127.0.0.1:<port>/admin`(`shell-preload.js` 暴露 `window.desktopShell`),不再调系统浏览器;面板页在浏览器里直接打开时标题栏自动不渲染 |
| 显示器菜单 | 面板页的「大屏控制 → 显示器」卡片经 `display:list` / `display:open` / `display:close` / `display:relayout` 指挥本机开窗:按屏创建、聚焦、收窗。**全屏只在「多于一块屏 + 非主屏」时放行**,判定在主进程做,页面传 `fullscreen: true` 也会被降级为窗口化;面板页只拿到这几条白名单通道,拿不到 `ipcRenderer` |
| 管理员账号 | 主进程 `spawn` 同目录 `scoreboard.exe passwd -config <同一份配置> -user <当前用户名> -new-username <新用户名> -password <新密码>`(留空 = 不改 / 随机生成)。本机进程、不经 HTTP,因此免登录验证;用户名/密码含空格、制表符或换行会被主进程拒绝,密码不进日志。`passwd` 会作废该用户全部会话,UI 提交前二次确认并提示 |
| 日志 | 内存环形缓冲(`log.maxLines`)+ 文件 `logs/console.log`(超 `maxFileBytes` 轮转一份 `.1`,轮转前会删掉旧 `.1` 以兼容 Windows) |
@@ -133,6 +134,8 @@ pnpm pack # 只出目录,不生成安装包,便于现场调试
- `extraFiles` 会把 `../dist/scoreboard.exe`、`../config.yaml`、`config.json`(exe 旁的可编辑配置,缺失时 Console 会回落到内置默认值)与 `../rules` 拷到安装目录,最终形成第 4 节里的部署包形态。
- `使用说明.txt` 是可选的:`deploy/build.bat` 在打包前依次找 `docs/使用说明.txt`、`docs/使用手册.md`,命中就暂存到 `console/extra/使用说明.txt`(`extraFiles` 会把该目录内容平铺到部署包根目录),都没有就跳过并在构建日志里注明。
- 图标在 `resources/icon.ico`(`package.json` 的 `win.icon` 指向它)。
- 主程序文件名由 `win.executableName` 决定,现在是 `Scoreboard_Console.exe`;`productName` 仍是 `Scoreboard Console`(显示名、`userData` 目录不变)。
- `nsis.include` 指向 `resources/installer-custom.nsh`(构建时由 `deploy/prepare-extra.mjs` 生成,已 gitignore):许可页顶部文案 + 记住上次安装目录(重装时默认沿用,不退回系统默认路径)。
- 已在 Windows 构建机上通过 `deploy\build.bat` 实跑(含 `pnpm dist`,产出 NSIS 安装包 + zip);Linux 开发机未安装 Electron/electron-builder。
## 尚未实机验证
@@ -140,6 +143,7 @@ pnpm pack # 只出目录,不生成安装包,便于现场调试
以下内容只做了静态语法检查,需要在 Windows + 真实多显示器环境跑一遍:
- 多屏 kiosk 定位与 `display-metrics-changed` 重排(改分辨率 / 缩放 / 插拔 HDMI)。
- 管理面板显示器菜单:真正的 kiosk 全屏 / 窗口化结果、`screen.getAllDisplays()` 的 `label` 与主屏判定、`display:list` 返回的 scaleFactor 是否与现场一致。
- `powerSaveBlocker` 在长时间后台下的实际效果。
- 托盘图标在 Windows 任务栏的显示效果(16px 缩放)。
- `scoreboard.exe` 真实拉起、日志转发、健康检查与崩溃重启的端到端链路。
+2 -2
View File
@@ -27,8 +27,8 @@
"openAtLogin": false
},
"admin": {
"width": 1040,
"height": 760
"width": 1280,
"height": 860
},
"log": {
"maxLines": 2000,
+2 -2
View File
@@ -43,8 +43,8 @@ const DEFAULTS = {
openAtLogin: false
},
admin: {
width: 1040,
height: 760
width: 1280,
height: 860
},
log: {
maxLines: 2000,
+63
View File
@@ -285,6 +285,69 @@ class DisplayManager {
this.layout()
}
// 窗口的当前全屏状态:以窗口为准,配置改了 kiosk 但窗口没重建时也能报对
isKioskWindow(win) {
if (!win || win.isDestroyed()) return false
return Boolean(win.isKiosk() || win.isFullScreen())
}
// 管理面板手动为某块屏开窗:已经开着就只聚焦,重建会打断正在展示的大屏
openOn(display, { page, single } = {}) {
const kiosk = !single && this.display.kiosk !== false
const wantedPage = page || this.pageFor(0)
const existing = this.windows.get(display.id)
if (existing && existing.win && !existing.win.isDestroyed()) {
if (existing.win.isMinimized()) existing.win.restore()
existing.win.show()
existing.win.focus()
// 已有窗口按它的真实状态报模式(可能与本次请求不同),面板才不会说反
return { created: false, mode: this.isKioskWindow(existing.win) ? 'kiosk' : 'windowed' }
}
// 已销毁的窗口占着 map 会让后续 layout 静默跳过,先摘掉旧 entry 再建
if (existing) {
if (existing.reloadTimer) clearInterval(existing.reloadTimer)
this.windows.delete(display.id)
}
// 手动开的窗口也计入 enabled:服务停止时 closeAll 才会把它一起收走
this.enabled = true
this.windows.set(display.id, this.createWindow(display, 0, !kiosk, wantedPage))
return { created: true, mode: kiosk ? 'kiosk' : 'windowed' }
}
// 关掉某块屏上的大屏窗口:kiosk 窗口刻意忽略鼠标,误开后只能从这里收
closeDisplay(displayId) {
const entry = this.windows.get(displayId)
if (!entry) return false
this.destroyEntry(displayId, entry)
return true
}
// 只读枚举显示器与窗口状态,给管理面板的显示器菜单用,不改动任何窗口
listDisplays() {
const all = screen.getAllDisplays()
const primary = screen.getPrimaryDisplay()
const list = all.map((display, index) => {
const entry = this.windows.get(display.id)
const open = Boolean(entry && entry.win && !entry.win.isDestroyed())
return {
id: display.id,
label: display.label || `显示器 ${display.id}`,
primary: display.id === primary.id,
// 一律给 DIP,与窗口坐标同一口径;物理像素对不上时看 scaleFactor
width: display.bounds.width,
height: display.bounds.height,
scaleFactor: display.scaleFactor,
open,
page: open ? entry.page : this.pageFor(index),
mode: open ? (this.isKioskWindow(entry.win) ? 'kiosk' : 'windowed') : null
}
})
// 单屏机器上谈「扩展屏全屏」没有意义,与 pickDisplays 同一口径
return { single: all.length <= 1, displays: list }
}
getInfo() {
return [...this.windows.entries()].map(([displayId, entry]) => ({
displayId,
+59 -1
View File
@@ -8,7 +8,8 @@ const {
Menu,
Tray,
ipcMain,
powerSaveBlocker
powerSaveBlocker,
screen
} = require('electron')
const { loadConfig, updateConfig, resolveExecutable } = require('./config')
@@ -247,6 +248,57 @@ function main() {
})
}
// 服务端管理面板是大屏窗口的第二个入口:页名只收 ASCII slug,
// 其余(含空值)交给 DisplayManager 回落配置里的默认页
function safeDisplayPage(value) {
return typeof value === 'string' && /^[a-z0-9-]{1,32}$/.test(value) ? value : null
}
function findDisplay(displayId) {
return screen.getAllDisplays().find((item) => String(item.id) === String(displayId)) || null
}
// 全屏窗口只允许出现在「多屏 + 非主屏」上:单屏或主屏开全屏会把用户桌面整个盖住,
// 现场又没有键盘能退出来。所以放不放行由主进程判定,页面传什么都不作数。
function openDisplayWindow(displayId, options) {
if (!displays) return { ok: false, created: false, mode: null, message: '显示器管理尚未就绪' }
// IPC 入参一律当不可信处理
const payload = options && typeof options === 'object' ? options : {}
const target = findDisplay(displayId)
if (!target) return { ok: false, created: false, mode: null, message: '未找到该显示器,可能已被拔出' }
const all = screen.getAllDisplays()
const singleMachine = all.length <= 1
const allowFullscreen = !singleMachine && target.id !== screen.getPrimaryDisplay().id
const wantedFullscreen = payload.fullscreen === true
const downgraded = wantedFullscreen && !allowFullscreen
const result = displays.openOn(target, {
page: safeDisplayPage(payload.page),
single: !(wantedFullscreen && allowFullscreen)
})
log.push('console', `管理面板${result.created ? '创建' : '聚焦'}大屏窗口 display=${target.id}(${result.mode === 'kiosk' ? '全屏 kiosk' : '窗口化'})`)
if (downgraded) {
const reason = singleMachine ? '当前只有一个显示器' : '主显示器'
log.push('console', `管理面板请求在 display=${target.id} 上开全屏:${reason}只能窗口化,已降级`)
}
return {
ok: true,
created: result.created,
mode: result.mode,
message: downgraded ? `${singleMachine ? '当前只有一个显示器' : '主显示器'}只能窗口化` : undefined
}
}
function closeDisplayWindow(displayId) {
if (!displays) return { ok: false, closed: false, message: '显示器管理尚未就绪' }
const target = findDisplay(displayId)
// 显示器拔了但窗口还在时,也允许按原 id 直接收窗
const closed = displays.closeDisplay(target ? target.id : Number(displayId))
return { ok: true, closed }
}
// 渲染层要随时知道最大化状态,才能在「最大化/还原」之间换图标
function bindWindowStateEvents(win) {
const notify = () => {
@@ -455,6 +507,12 @@ function main() {
if (displays && displays.enabled) displays.relayout()
return statusSnapshot()
})
ipcMain.handle('display:list', () => {
if (!displays) return { single: true, displays: [] }
return displays.listDisplays()
})
ipcMain.handle('display:open', (event, displayId, options) => openDisplayWindow(displayId, options))
ipcMain.handle('display:close', (event, displayId) => closeDisplayWindow(displayId))
ipcMain.handle('app:open-admin-window', () => {
showAdmin()
return true
+2 -1
View File
@@ -58,6 +58,7 @@
}
],
"win": {
"executableName": "Scoreboard_Console",
"icon": "resources/icon.ico",
"target": [
{
@@ -77,7 +78,7 @@
"createDesktopShortcut": true,
"shortcutName": "赛事积分榜",
"license": "../LICENSE",
"include": "resources/installer-signature.nsh",
"include": "resources/installer-custom.nsh",
"language": "2052"
}
}
+119 -114
View File
@@ -46,130 +46,135 @@
</header>
<main>
<section class="card">
<div class="card-head">
<h2>服务端状态</h2>
<div class="actions">
<button id="btn-start" class="btn primary" type="button">启动服务</button>
<button id="btn-stop" class="btn" type="button">停止服务</button>
<button id="btn-restart" class="btn" type="button">重启服务</button>
<!-- 左栏放状态与配置类卡片,右栏整栏留给运行日志 -->
<div class="col-left">
<section class="card">
<div class="card-head">
<h2>服务端状态</h2>
<div class="actions">
<button id="btn-start" class="btn primary" type="button">启动服务</button>
<button id="btn-stop" class="btn" type="button">停止服务</button>
<button id="btn-restart" class="btn" type="button">重启服务</button>
</div>
</div>
</div>
<div class="status-row">
<span id="state-dot" class="dot"></span>
<strong id="state-label">已停止</strong>
<span id="state-detail" class="muted"></span>
</div>
<div class="status-row">
<span id="state-dot" class="dot"></span>
<strong id="state-label">已停止</strong>
<span id="state-detail" class="muted"></span>
</div>
<dl class="kv">
<div>
<dt>服务地址</dt>
<dd id="k-url">—</dd>
</div>
<div>
<dt>进程 PID</dt>
<dd id="k-pid">—</dd>
</div>
<div>
<dt>自动重启</dt>
<dd id="k-restarts">—</dd>
</div>
<div>
<dt>防熄屏</dt>
<dd id="k-power">—</dd>
</div>
<div>
<dt>开机自启</dt>
<dd>
<label class="switch">
<input id="sw-open-at-login" type="checkbox" />
<span id="sw-open-at-login-label" class="muted">—</span>
</label>
</dd>
</div>
<div class="wide">
<dt>服务端程序</dt>
<dd id="k-exe" class="mono">—</dd>
</div>
<div class="wide">
<dt>配置文件</dt>
<dd id="k-config" class="mono">—</dd>
</div>
</dl>
<dl class="kv">
<div>
<dt>服务地址</dt>
<dd id="k-url">—</dd>
</div>
<div>
<dt>进程 PID</dt>
<dd id="k-pid">—</dd>
</div>
<div>
<dt>自动重启</dt>
<dd id="k-restarts">—</dd>
</div>
<div>
<dt>防熄屏</dt>
<dd id="k-power">—</dd>
</div>
<div>
<dt>开机自启</dt>
<dd>
<label class="switch">
<input id="sw-open-at-login" type="checkbox" />
<span id="sw-open-at-login-label" class="muted">—</span>
</label>
</dd>
</div>
<div class="wide">
<dt>服务端程序</dt>
<dd id="k-exe" class="mono">—</dd>
</div>
<div class="wide">
<dt>配置文件</dt>
<dd id="k-config" class="mono">—</dd>
</div>
</dl>
<p id="config-warning" class="warning" hidden></p>
</section>
<p id="config-warning" class="warning" hidden></p>
</section>
<section class="card">
<div class="card-head">
<h2>大屏窗口</h2>
<div class="actions">
<button id="btn-relayout" class="btn" type="button">重新排布</button>
<button id="btn-open-admin" class="btn" type="button">打开服务端管理面板</button>
<section class="card">
<div class="card-head">
<h2>大屏窗口</h2>
<div class="actions">
<button id="btn-relayout" class="btn" type="button">重新排布</button>
<button id="btn-open-admin" class="btn" type="button">打开服务端管理面板</button>
</div>
</div>
</div>
<p class="hint">
单屏时窗口化、按 F11 切换全屏;多屏时除内置屏外(无内置屏时除主屏外)每块屏一个 kiosk 窗口,各锁一页。
</p>
<ul id="display-list" class="display-list">
<li class="muted">尚未打开大屏窗口</li>
</ul>
</section>
<p class="hint">
单屏时窗口化、按 F11 切换全屏;多屏时除内置屏外(无内置屏时除主屏外)每块屏一个 kiosk 窗口,各锁一页。
</p>
<ul id="display-list" class="display-list">
<li class="muted">尚未打开大屏窗口</li>
</ul>
</section>
<section class="card">
<div class="card-head">
<h2>已连接的大屏实例</h2>
<span id="instances-summary" class="muted"></span>
</div>
<p class="hint">
来自服务端 <span class="mono">GET /api/display/instances</span>,每 5 秒刷新一次;
沿用服务端管理面板的登录态,需要记录员及以上权限。
</p>
<ul id="instance-list" class="display-list">
<li class="muted">正在读取…</li>
</ul>
</section>
<section class="card">
<div class="card-head">
<h2>管理员账号</h2>
<div class="actions">
<button id="btn-reset-account" class="btn" type="button">重置账号</button>
<section class="card">
<div class="card-head">
<h2>已连接的大屏实例</h2>
<span id="instances-summary" class="muted"></span>
</div>
</div>
<p class="hint">
本机直接调用同目录的 <span class="mono">scoreboard.exe passwd</span>,无需登录验证。
重置会作废该用户的全部登录会话,已在用的管理面板会被强制退出。
</p>
<div class="form-row">
<label class="field">
<span>当前用户名</span>
<input id="acct-user" class="input" type="text" value="admin" autocomplete="off" spellcheck="false" />
</label>
<label class="field">
<span>新用户名(留空 = 不改)</span>
<input id="acct-new-user" class="input" type="text" autocomplete="off" spellcheck="false" />
</label>
<label class="field">
<span>新密码(留空 = 随机生成)</span>
<input id="acct-password" class="input" type="password" autocomplete="new-password" spellcheck="false" />
</label>
</div>
<p id="acct-status" class="hint" hidden></p>
<pre id="acct-output" class="log account-output" hidden></pre>
</section>
<p class="hint">
来自服务端 <span class="mono">GET /api/display/instances</span>,每 5 秒刷新一次;
沿用服务端管理面板的登录态,需要记录员及以上权限。
</p>
<ul id="instance-list" class="display-list">
<li class="muted">正在读取…</li>
</ul>
</section>
<section class="card log-card">
<div class="card-head">
<h2>运行日志</h2>
<div class="actions">
<label class="check"><input id="autoscroll" type="checkbox" checked /> 自动滚动</label>
<button id="btn-clear-log" class="btn" type="button">清空</button>
<section class="card">
<div class="card-head">
<h2>管理员账号</h2>
<div class="actions">
<button id="btn-reset-account" class="btn" type="button">重置账号</button>
</div>
</div>
</div>
<pre id="log" class="log"></pre>
</section>
<p class="hint">
本机直接调用同目录的 <span class="mono">scoreboard.exe passwd</span>,无需登录验证。
重置会作废该用户的全部登录会话,已在用的管理面板会被强制退出。
</p>
<div class="form-row">
<label class="field">
<span>当前用户名</span>
<input id="acct-user" class="input" type="text" value="admin" autocomplete="off" spellcheck="false" />
</label>
<label class="field">
<span>新用户名(留空 = 不改)</span>
<input id="acct-new-user" class="input" type="text" autocomplete="off" spellcheck="false" />
</label>
<label class="field">
<span>新密码(留空 = 随机生成)</span>
<input id="acct-password" class="input" type="password" autocomplete="new-password" spellcheck="false" />
</label>
</div>
<p id="acct-status" class="hint" hidden></p>
<pre id="acct-output" class="log account-output" hidden></pre>
</section>
</div>
<div class="col-right">
<section class="card log-card">
<div class="card-head">
<h2>运行日志</h2>
<div class="actions">
<label class="check"><input id="autoscroll" type="checkbox" checked /> 自动滚动</label>
<button id="btn-clear-log" class="btn" type="button">清空</button>
</div>
</div>
<pre id="log" class="log"></pre>
</section>
</div>
</main>
<script src="app.js"></script>
+9 -1
View File
@@ -2,7 +2,7 @@
const { contextBridge, ipcRenderer } = require('electron')
// 服务端管理面板是 http://127.0.0.1 下的 Web 页面,只放行窗口控制这一组能力,
// 服务端管理面板是 http://127.0.0.1 下的 Web 页面,只放行窗口与显示器控制这两组能力,
// 不把 ipcRenderer 交给页面,避免面板页面拿到主进程的任意通道
contextBridge.exposeInMainWorld('desktopShell', {
platform: process.platform,
@@ -14,5 +14,13 @@ contextBridge.exposeInMainWorld('desktopShell', {
const handler = (_event, maximized) => callback(maximized)
ipcRenderer.on('window:maximize-changed', handler)
return () => ipcRenderer.removeListener('window:maximize-changed', handler)
},
// 显示器菜单:列屏 / 按屏开窗 / 收窗 / 按设置重建。全屏放行与否由主进程判定,
// 这里只透传,页面拿不到 ipcRenderer 本身
displays: {
list: () => ipcRenderer.invoke('display:list'),
open: (displayId, options) => ipcRenderer.invoke('display:open', displayId, options),
close: (displayId) => ipcRenderer.invoke('display:close', displayId),
relayout: () => ipcRenderer.invoke('display:relayout')
}
})
+46 -6
View File
@@ -151,14 +151,28 @@ main {
padding: 16px 20px 20px;
display: grid;
gap: 14px;
grid-template-columns: 1fr 1fr;
/* 五张卡片各占一整行;最后一行(运行日志)吃掉剩余高度 */
grid-template-rows: auto auto auto auto minmax(160px, 1fr);
/* 左栏稍宽放卡片,右栏整栏给运行日志 */
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
/* 滚动交给两栏各自处理,否则整页滚动时日志也跟着跑 */
overflow: hidden;
}
.col-left {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
/* 左栏卡片总高超出时左栏自己滚,不影响右栏日志 */
overflow: auto;
}
.col-right {
display: flex;
flex-direction: column;
min-height: 0;
}
.card {
grid-column: span 2;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 10px;
@@ -168,6 +182,8 @@ main {
.log-card {
display: flex;
flex-direction: column;
/* 撑满右栏整高,日志区随窗口高度伸缩 */
flex: 1;
min-height: 0;
}
@@ -256,11 +272,13 @@ h2 {
.kv {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
/* 左栏只有半屏宽,4 列会挤成窄条,改 2 列 */
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 16px;
margin: 0;
}
/* 2 列下 span 2 正好是一整行,长路径仍然通栏 */
.kv > div.wide {
grid-column: span 2;
}
@@ -410,7 +428,8 @@ h2 {
.form-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
/* 左栏宽度不定:够宽排 3 个,窄了自动换行成 2 个 / 1 个 */
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 10px 16px;
}
@@ -471,3 +490,24 @@ h2 {
max-height: 180px;
margin: 10px 0 0;
}
/* 窗口拖窄到放不下两栏时退回单栏,日志仍排在最后并保留最小高度 */
@media (max-width: 900px) {
/* 这里必须换成 flex:栅格下 auto 行会被压缩,左栏内容会被裁掉而不是撑开滚动 */
main {
display: flex;
flex-direction: column;
overflow: auto;
}
/* 单栏时整页一起滚,左栏按内容高、不再自成一个滚动区 */
.col-left {
flex: none;
overflow: visible;
}
.col-right {
flex: 1;
min-height: 200px;
}
}