Files
Scoreboard/server/internal/httpapi/network.go
T
AskaEth 9ef68c2589 feat(console,admin): kiosk 不再点击穿透 + 压住任务栏;大屏控制页新增「局域网大屏」(链接/复制/二维码)dev_0.4.0
## ① 现场问题:kiosk 窗口点击穿透(原设计方向反了)
`displays.js` 原来对多屏 kiosk 调 `setIgnoreMouseEvents(true)`,注释写的是
「防止现场误触」——但该 API 的语义是「本窗口忽略鼠标」,点击会**落到它后面的
窗口或桌面**,现场想防的误触正好变成误点别的东西;被忽略鼠标的窗口也点不活。
现在窗口**默认吞掉鼠标、不穿透**。

配置项**改名**为 `display.allowClickThrough`(缺省 false;显式 true 才穿透)。
**旧键 `display.ignoreMouse` 完全不读**——老机器的配置文件里还写着旧版模板留下的
`ignoreMouse: true`,而配置策略是「已存在不覆盖」,只改默认值等于老机器升级后
照样穿透;改名才能让默认值真正生效。启动时若发现旧键会记一条中文提示(只记一次),
并在合并后的配置里把它剥掉,防止将来有人再读到那个陈年 true。
(不是自动改写用户配置文件:无法区分「操作员刻意写的 true」与「旧模板留下的 true」。)

## ② 现场问题:任务栏盖住 kiosk 窗口、被盖住后抢不回前台
原顺序是 `setKiosk(true)` 之后又 `setBounds(...)`,在 Windows 上会把窗口从真正的
全屏态拽回「恰好铺满该屏的无边框窗口」→ 不是 topmost,任务栏与拖过去的窗口都盖得住。
现在固定为 **先 `setBounds` 定位 → 再 `setKiosk(true)` → 最后 `setAlwaysOnTop(true,
'screen-saver')`**(默认层级 `floating` 在 Windows 上仍画在任务栏下面),并加了
「已达标就不重复进出 kiosk」的守卫避免重排白闪;非 kiosk 分支会摘掉置顶。
Win 真机行为需现场复核,代码/桩级已验(含顺序与层级的断言)。

## ③ 新功能:大屏控制页「局域网大屏」卡片
- 服务端新增 `GET /api/system/network`(recorder 及以上):回本机可用局域网 IPv4
  (排除回环/link-local/IPv6,去重,按 192.168/10/172.16-31 优先排序)+ 真实监听端口
  + 路径;无地址回 `[]` 不报错。
- 卡片:每个地址一条可点击链接 + 复制按钮,第一个地址配二维码(148px)。
- **二维码是离线自实现的纯 TS 编码器**(版本 1–40 自动、ECC L/M/Q/H、8 种掩码择优),
  正确性用 **Python OpenCV 真解码**验收:29/29 条解出原文(含中文与边界),
  SVG 渲染后再解码 4/4,并把验证过的矩阵冻结成仓库参照用例。
- 复制按**非安全上下文**处理:面板常从 `http://192.168.x.x` 打开,那里
  `navigator.clipboard` 是 undefined,自动回退 `execCommand('copy')`。

## ④ 真机联调抓出并修掉一个时序缺陷(真浏览器 + 真服务端)
`DisplayPage` 的 `refreshLan()` 在挂载时跑,而 `auth` 是异步加载的——那一刻
`auth.readOnly` 必然为 true,卡片把「当前为只读访客」写进一个**普通 ref** 就返回了,
身份落定后没有任何东西再触发它:**管理员登录后卡片永远停在访客态,
`/api/system/network` 一次都没被请求**。
改为「gate 由 computed 直接渲染 + watch gate 变 null 时重跑加载」,并补回归钉子。
(同时修正了一条把该错误结构当成正确行为的源码断言用例。)
端到端证据:真服务 + 局域网 IP(真非安全上下文)→ 卡片列出两个地址、点击复制变
「已复制」(走 execCommand 回退)→ 浏览器渲染的二维码截图经 cv2 解出
`http://10.50.101.85:19861/display`。

## 文档
部署指南配置表与多屏行为、使用手册「大屏控制」与局域网设备段、devdocs 05/07/10/13、
升级说明新增 dev_0.4.0(含旧键废弃与启动提示)。**无数据迁移**,旧赛事包照常导入。

自检:服务端 13 包 ×2;前端 vue-tsc + 645 例 ×2 + build;Console 20 文件 + 68 例。
2026-09-12 16:25:04 +08:00

188 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"bytes"
"net"
"net/http"
"sort"
)
// 大屏控制页要把「当前设备的局域网地址」显示成可点链接 + 二维码:面板自己往往是从
// 127.0.0.1 打开的,浏览器里的 location.host 对平板毫无用处,所以地址只能由服务端枚举网卡得到。
//
// 路径也随响应一起下发:/ → index.html、/display → display.html、/admin → admin.html 的映射
// 写在 server.go 的 staticHandler 里。前端各自硬编码一份,改前端产物名时就会有两处要跟着动。
const (
displayPagePath = "/display"
adminPagePath = "/admin"
)
// lanAddress 是一个「局域网里其他设备能连上这台机器」的候选地址。
//
// family 目前恒为 ipv4:IPv6 要写成 http://[fe80::1%eth0]:port/display,方括号与 %zone
// 粘到平板浏览器里出错率高,而现场本来就是 IPv4 内网。留着这个字段是为了以后真要补 IPv6 时
// 前端不必改结构(见 06/07 文档里的前端约定)。
type lanAddress struct {
IP string `json:"ip"`
Interface string `json:"interface"`
Family string `json:"family"`
}
// networkInfo 是 GET /api/system/network 的响应体。
type networkInfo struct {
Port int `json:"port"`
DisplayPath string `json:"displayPath"`
AdminPath string `json:"adminPath"`
Addresses []lanAddress `json:"addresses"`
}
// ifaceSnapshot 是 net.Interface 里本接口用得上的那部分。
//
// 单独抽出来是为了让筛选与排序成为纯函数:net.Interface 的 Addrs() 直接问操作系统,
// 拿真网卡根本造不出「网线 + WiFi 双网卡」「两张网卡配同一个地址」这类现场,
// 而这正是最需要钉住的行为。测试构造快照即可,真网卡一行都不用碰。
type ifaceSnapshot struct {
Name string
Flags net.Flags
Addrs []net.Addr
}
// snapshotInterfaces 读一次本机网卡。读不到(极罕见)时返回空切片:
// 前端据此显示「未检测到局域网地址」,比报错更接近事实——服务本身是好的。
func (s *server) snapshotInterfaces() []ifaceSnapshot {
list, err := net.Interfaces()
if err != nil {
s.logger.Warn("枚举网卡失败,大屏控制页将显示「未检测到局域网地址」", "err", err)
return nil
}
out := make([]ifaceSnapshot, 0, len(list))
for _, iface := range list {
addrs, err := iface.Addrs()
if err != nil {
// 单张网卡读不到地址不影响别的网卡:现场常见的是一张 down 掉的虚拟网卡报错。
continue
}
out = append(out, ifaceSnapshot{Name: iface.Name, Flags: iface.Flags, Addrs: addrs})
}
return out
}
// lanAddresses 从网卡快照里挑出可用的局域网 IPv4 地址,按「现场最可能用的那个」排在最前。
// 结果永远非 nil:没有任何可用地址时返回空切片,前端才知道这是「没有」而不是「没拿到字段」。
func lanAddresses(ifaces []ifaceSnapshot) []lanAddress {
// 候选先带上 4 字节形式:去重、排序用得到,且避免在比较器里反复做 To4。
type candidate struct {
entry lanAddress
ip4 net.IP
}
seen := make(map[string]struct{})
cands := make([]candidate, 0, len(ifaces))
for _, iface := range ifaces {
if !usableInterface(iface.Flags) {
continue
}
for _, raw := range iface.Addrs {
ip4 := addrIP(raw).To4()
if ip4 == nil || !usableLanIPv4(ip4) {
continue
}
key := ip4.String()
// 同一地址配在多个接口上(桥接、双网卡同网段)时只留先出现的一条:
// 列表里两条一模一样的链接,现场会以为是两个不同的东西。
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
cands = append(cands, candidate{
entry: lanAddress{IP: key, Interface: iface.Name, Family: "ipv4"},
ip4: ip4,
})
}
}
sort.Slice(cands, func(i, j int) bool {
a, b := cands[i], cands[j]
if ra, rb := lanRank(a.ip4), lanRank(b.ip4); ra != rb {
return ra < rb
}
// 同档按地址数值排序:同一台机器每次刷新都要给出同样的顺序,
// 否则列表第一个地址会跳来跳去,现场刚记住的链接下次就变了。
if c := bytes.Compare(a.ip4, b.ip4); c != 0 {
return c < 0
}
return a.entry.Interface < b.entry.Interface
})
out := make([]lanAddress, 0, len(cands))
for _, c := range cands {
out = append(out, c.entry)
}
return out
}
// usableInterface 判断一张网卡能不能贡献地址:
// - 必须 UP —— 网线没插、接口被 down 掉时,配置还在但包根本发不出去;
// - 排除回环接口 —— lo 上的 127.x 对别的设备毫无意义(面板自己就是从 127.0.0.1 打开的,
// 真把它显示出来会误导现场照抄一个只有本机能打开的地址)。
func usableInterface(flags net.Flags) bool {
return flags&net.FlagUp != 0 && flags&net.FlagLoopback == 0
}
// usableLanIPv4 判断一个 IPv4 地址能不能给局域网其他设备用。
// 逐条排除的都是「拿得到、连不上」的地址:
// - 127.0.0.0/8 回环:只有本机能访问;
// - 169.254.0.0/16 link-local:DHCP 没拿到地址时系统自配的,同网段设备通常也到不了;
// - 0.0.0.0:未指定地址,拼不成 URL;
// - 224.0.0.0/4 组播:不是单播目的地址。
func usableLanIPv4(ip4 net.IP) bool {
switch {
case ip4.IsLoopback(), ip4.IsUnspecified(), ip4.IsLinkLocalUnicast(), ip4.IsMulticast():
return false
}
return true
}
// lanRank 是排序档位(越小越靠前):现场「网线 + WiFi 双网卡」时,排第一的应该是他们真正在用的那个。
//
// 分档依据是各私有段在实际现场的常见程度:
// - 0:192.168.0.0/16 —— 家用与小型办公路由器的出厂段,现场路由器 / 无线热点几乎都在这里;
// - 1:10.0.0.0/8 —— 稍大的内网、企业路由器与手机热点(部分机型)常用的段;
// - 2:172.16.0.0/12 —— RFC1918 三段里最少见的,多是虚拟化 / 容器网桥(docker 默认就用它),
// 真要拿它给平板用也得排在直连网段之后;
// - 3:其余(公网地址、运营商专线地址等)—— 能用就留着,但不给第一顺位:
// 现场多半是靠某个中间设备才通的,未必是平板能直连的那个。
func lanRank(ip4 net.IP) int {
switch {
case ip4[0] == 192 && ip4[1] == 168:
return 0
case ip4[0] == 10:
return 1
case ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31:
return 2
default:
return 3
}
}
// addrIP 从 net.Addr 里取出 IP。网卡地址实际都是 *net.IPNet,*net.IPAddr 只是兜底。
func addrIP(addr net.Addr) net.IP {
switch a := addr.(type) {
case *net.IPNet:
return a.IP
case *net.IPAddr:
return a.IP
}
return nil
}
// systemNetwork 是 GET /api/system/network:大屏控制页用它拼出平板能直接打开的链接。
// 端口取服务端实际监听的那个(见 server.port),不在这里另写一个数字。
func (s *server) systemNetwork(w http.ResponseWriter, r *http.Request) (any, error) {
return networkInfo{
Port: s.port,
DisplayPath: displayPagePath,
AdminPath: adminPagePath,
Addresses: lanAddresses(s.snapshotInterfaces()),
}, nil
}