## ① 现场问题: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 例。
188 lines
7.1 KiB
Go
188 lines
7.1 KiB
Go
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
|
||
}
|