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 例。
This commit is contained in:
AskaEth committed 2026-09-12 16:25:04 +08:00
1 parent ed2c0b3f3c
commit 9ef68c2589
33 files changed
+2923 -95

No files matched your search

+187
View File
@@ -0,0 +1,187 @@
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
}
@@ -0,0 +1,145 @@
package httpapi
import (
"encoding/json"
"net"
"reflect"
"strings"
"testing"
)
// lanIf 造一张网卡快照:测试里要的是「现场双网卡」这类布局,不能去动真网卡。
func lanIf(name string, flags net.Flags, cidrs ...string) ifaceSnapshot {
addrs := make([]net.Addr, 0, len(cidrs))
for _, cidr := range cidrs {
ip, ipnet, err := net.ParseCIDR(cidr)
if err != nil {
panic("测试地址非法: " + cidr)
}
addrs = append(addrs, &net.IPNet{IP: ip, Mask: ipnet.Mask})
}
return ifaceSnapshot{Name: name, Flags: flags, Addrs: addrs}
}
func lanIPs(got []lanAddress) []string {
out := make([]string, 0, len(got))
for _, a := range got {
out = append(out, a.IP)
}
return out
}
// 只回「平板真的连得上」的 IPv4:回环、link-local、未指定、组播、IPv6、down 掉的接口全要滤掉。
// 这条用例钉住的是现场最容易踩的坑——面板从 127.0.0.1 打开,顺手把回环地址显示出去,
// 现场照着抄到平板上就是打不开。
func TestLanAddressesFiltersUnusable(t *testing.T) {
ifaces := []ifaceSnapshot{
lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8", "::1/128"),
// 网线插着但 DHCP 没拿到地址:系统自配 169.254.x,同网段设备通常也到不了。
lanIf("eth0", net.FlagUp, "169.254.10.5/16", "fe80::a00:27ff:fe4e:66a1/64"),
// 接口 down 掉:配置还在,包发不出去。
lanIf("eth1", 0, "192.168.5.5/24"),
// 非回环接口上被误配了回环地址(别名地址写错段):按地址本身也得滤掉,
// 光看接口标志漏得掉这一条。
lanIf("eth2", net.FlagUp, "127.0.0.5/8"),
lanIf("docker0", net.FlagUp, "0.0.0.0/32", "224.0.0.1/4", "172.20.0.1/16"),
// 双栈内网里网卡还会带全局 IPv6:拼 URL 要写方括号且常带 %zone,平板粘过去容易出错,
// 所以一律不往下发(family 字段留着,将来真要用才补)。
lanIf("wlan0", net.FlagUp, "2400:3200::1/64", "10.1.2.3/8"),
}
want := []string{"10.1.2.3", "172.20.0.1"}
got := lanAddresses(ifaces)
if !reflect.DeepEqual(lanIPs(got), want) {
t.Fatalf("可用地址应为 %v,得到 %v", want, lanIPs(got))
}
// family 是给前端拼 URL 用的:目前只回 IPv4,字段也必须存在。
for _, a := range got {
if a.Family != "ipv4" {
t.Fatalf("family 应为 ipv4,得到 %q(%s)", a.Family, a.IP)
}
}
}
// 一个地址都挑不出来时必须回空切片而不是 nil:JSON 里要是 null,前端分不清
// 「没有局域网地址」和「服务端没给这个字段」。
func TestLanAddressesEmptyResultIsNonNil(t *testing.T) {
cases := map[string][]ifaceSnapshot{
"没有网卡": nil,
"只有回环": {lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8")},
"只有 IPv6": {lanIf("eth0", net.FlagUp, "fe80::1/64")},
"接口全 down": {lanIf("eth0", 0, "192.168.1.23/24")},
}
for name, ifaces := range cases {
got := lanAddresses(ifaces)
if got == nil {
t.Fatalf("%s:应回空切片而不是 nil", name)
}
if len(got) != 0 {
t.Fatalf("%s:不该有地址,得到 %v", name, lanIPs(got))
}
raw, err := json.Marshal(networkInfo{Port: 1, Addresses: got})
if err != nil {
t.Fatalf("%s:序列化失败 %v", name, err)
}
if !strings.Contains(string(raw), `"addresses":[]`) {
t.Fatalf("%s:addresses 应序列化为 [],得到 %s", name, raw)
}
}
}
// 同一地址配在多张网卡上(桥接、双网卡同网段)时只留一条,且保留先出现的那张网卡名;
// 顺序按「像不像常见内网段」分档,同档再按地址数值升序——否则列表每次刷新都在跳,
// 现场刚记住的链接下次就不是第一条了。
func TestLanAddressesDedupAndOrder(t *testing.T) {
ifaces := []ifaceSnapshot{
// 常见的「网线 eth0 + WiFi wlan0」现场:WiFi 落在路由器出厂段 192.168.x。
lanIf("eth0", net.FlagUp, "172.20.1.5/16", "192.168.1.23/24", "8.8.8.8/32"),
lanIf("wlan0", net.FlagUp, "192.168.1.9/24", "10.0.0.4/8", "192.168.1.100/24"),
lanIf("br0", net.FlagUp, "192.168.1.23/24"),
}
want := []string{"192.168.1.9", "192.168.1.23", "192.168.1.100", "10.0.0.4", "172.20.1.5", "8.8.8.8"}
got := lanAddresses(ifaces)
if !reflect.DeepEqual(lanIPs(got), want) {
t.Fatalf("地址顺序应为 %v,得到 %v", want, lanIPs(got))
}
// 192.168.1.23 先出现在 eth0 上,br0 上的同一地址要被丢掉、也不能改写 interface。
if got[1].Interface != "eth0" {
t.Fatalf("去重后应保留先出现的接口名 eth0,得到 %q", got[1].Interface)
}
}
// 排序档位本身:192.168/16 → 10/8 → 172.16-31/12 → 其余,越靠前越是现场真在用的那条。
func TestLanRankPrefersCommonLanSegments(t *testing.T) {
order := []string{
"192.168.0.1", "192.168.255.254", // 路由器出厂段
"10.0.0.1", "10.255.255.254", // 企业内网 / 热点
"172.16.0.1", "172.31.255.254", // 最少见,多是虚拟网桥
"172.15.0.1", "172.32.0.1", "203.0.113.9", // 172.16/12 之外
}
for i := 1; i < len(order); i++ {
prev, curr := net.ParseIP(order[i-1]).To4(), net.ParseIP(order[i]).To4()
if lanRank(prev) > lanRank(curr) {
t.Fatalf("%s 不该排在 %s 之后(%d > %d)", order[i-1], order[i], lanRank(prev), lanRank(curr))
}
}
if lanRank(net.ParseIP("172.15.0.1").To4()) != 3 || lanRank(net.ParseIP("172.32.0.1").To4()) != 3 {
t.Fatal("172.16/12 的两侧边界不该被当成常见内网段")
}
}
// 网卡地址实际都是 *net.IPNet,但兜底分支也要能取到 IP,否则换平台(如某些 BSD/虚拟网卡)
// 会静默把地址全滤掉,现场表现为「明明有网却显示未检测到」。
func TestAddrIPHandlesBothAddrTypes(t *testing.T) {
if got := addrIP(&net.IPAddr{IP: net.ParseIP("192.168.1.23")}); got.String() != "192.168.1.23" {
t.Fatalf("*net.IPAddr 应取到 IP,得到 %v", got)
}
if got := addrIP(&net.IPNet{IP: net.ParseIP("10.0.0.4"), Mask: net.CIDRMask(8, 32)}); got.String() != "10.0.0.4" {
t.Fatalf("*net.IPNet 应取到 IP,得到 %v", got)
}
// 认不出的地址类型不能让整条链路 panic。
if got := addrIP(nil); got != nil {
t.Fatalf("未知地址类型应返回 nil,得到 %v", got)
}
}
+109
View File
@@ -0,0 +1,109 @@
package httpapi_test
import (
"encoding/json"
"net"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/config"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
)
// GET /api/system/network 是给大屏控制页拼「平板能直接打开的链接」用的:
// recorder 起可读,guest 与未登录一律 403(与 /api/system/about 同档——机器级信息不给匿名访客)。
func TestSystemNetworkPermission(t *testing.T) {
env := newTestEnvWith(t, func(o *httpapi.Options) { o.Port = 18080 })
guest, _ := env.client()
if status, env2 := env.do(guest, http.MethodGet, "/api/system/network", nil, false); status != http.StatusForbidden ||
env2.Error == nil || env2.Error.Code != "FORBIDDEN" {
t.Fatalf("guest 读 network 应 403 FORBIDDEN,得到 %d %+v", status, env2.Error)
}
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodGet, "/api/system/network", nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("recorder 读 network 应 200,得到 %d %+v", status, env2.Error)
}
}
// 响应形状是对前端契约:字段一个不多一个不少,port 必须是实际监听端口(测试注入 18080),
// 路径字段则要与服务端真的托管着的页面一致——前端是照它拼链接的,写错了就是死链。
func TestSystemNetworkShape(t *testing.T) {
env := newTestEnvWith(t, func(o *httpapi.Options) { o.Port = 18080 })
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodGet, "/api/system/network", nil, false)
if status != http.StatusOK {
t.Fatalf("读 network 应 200,得到 %d", status)
}
var body map[string]any
if err := json.Unmarshal(env2.Data, &body); err != nil {
t.Fatalf("解析 network: %v (%s)", err, env2.Data)
}
assertKeys(t, "network", body, []string{"port", "displayPath", "adminPath", "addresses"})
if got, ok := body["port"].(float64); !ok || int(got) != 18080 {
t.Fatalf("port 应回注入的实际监听端口 18080,得到 %#v", body["port"])
}
if body["displayPath"] != "/display" || body["adminPath"] != "/admin" {
t.Fatalf("路径字段不符: displayPath=%#v adminPath=%#v", body["displayPath"], body["adminPath"])
}
// 路径字段指向的页面必须真的存在:改了前端产物名就会在这里红灯,而不是等到现场打开是 404。
for _, path := range []string{body["displayPath"].(string), body["adminPath"].(string)} {
code, raw := env.doRaw(client, http.MethodGet, path, nil, "", false)
if code != http.StatusOK || !strings.Contains(string(raw), "<html") {
t.Fatalf("路径 %s 应返回页面,得到 %d %.80s", path, code, raw)
}
}
// addresses 必须是数组(没有地址时是 [] 而不是 null),每条都带 ip/interface/family 且是 IPv4。
raw, ok := body["addresses"].([]any)
if !ok {
t.Fatalf("addresses 应是数组,得到 %#v", body["addresses"])
}
for _, item := range raw {
entry, ok := item.(map[string]any)
if !ok {
t.Fatalf("地址项应是对象,得到 %#v", item)
}
assertKeys(t, "addresses[]", entry, []string{"ip", "interface", "family"})
if entry["family"] != "ipv4" {
t.Fatalf("family 应为 ipv4,得到 %#v", entry["family"])
}
ip := net.ParseIP(entry["ip"].(string))
if ip == nil || ip.To4() == nil {
t.Fatalf("ip 应是 IPv4 文本,得到 %#v", entry["ip"])
}
// 回环与 link-local 是「拿得到、连不上」的地址,绝不能出现在给平板用的列表里。
if ip.IsLoopback() || ip.IsLinkLocalUnicast() {
t.Fatalf("不该出现回环 / link-local 地址: %#v", entry)
}
}
}
// 调用方忘了注入 Port 时也要给出一个真端口(config 的默认端口),不能是 :0 —— 那会拼出
// 一条根本打不开的链接。默认值的唯一来源是 config 包,本用例直接对比它,防止两处漂移。
func TestSystemNetworkPortFallsBackToConfigDefault(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
_, env2 := env.do(client, http.MethodGet, "/api/system/network", nil, false)
var body map[string]any
if err := json.Unmarshal(env2.Data, &body); err != nil {
t.Fatalf("解析 network: %v (%s)", err, env2.Data)
}
want := config.Default().Server.Port
if got, ok := body["port"].(float64); !ok || int(got) != want {
t.Fatalf("未注入端口时应回 config 默认端口 %d,得到 %#v", want, body["port"])
}
}
+24 -5
View File
@@ -14,6 +14,7 @@ import (
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/config"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
"git.yeij.top/AskaEth/Scoreboard/server/internal/webui"
@@ -51,6 +52,10 @@ type Options struct {
WS http.Handler
// ProjectDir 可选,当前赛事项目目录(含 uploads/),用于赛事包导出。
ProjectDir string
// Port 是服务端**实际监听**的端口,由 main 从 config 传入;GET /api/system/network
// 用它拼出「平板能直接打开的链接」。<=0(调用方没传)时按 config 的默认端口兜底,
// 不让响应里出现 :0 —— 详见 New。
Port int
// WebFS 可选,默认使用内嵌前端资源。
WebFS fs.FS
}
@@ -63,11 +68,13 @@ type server struct {
auth *auth.Service
dataDir string
projectDir string
backups *export.Service
shutdown func()
display DisplaySender
realtime Realtime
idem *idempotencyStore
// port 是实际监听端口,只读地传给 GET /api/system/network(见 Options.Port)。
port int
backups *export.Service
shutdown func()
display DisplaySender
realtime Realtime
idem *idempotencyStore
}
// New 组装并返回服务端的 HTTP 处理器。
@@ -88,6 +95,12 @@ func New(opts Options) http.Handler {
}
backups = export.NewService(opts.System, opts.Event, dir, 0, logger)
}
// 端口取实际监听值。调用方漏传时用 config 的默认端口兜底,而不是在这里另抄一个数字:
// 响应里出现 19540 至少是个真端口,出现 :0 会让前端拼出一条根本打不开的链接。
port := opts.Port
if port <= 0 {
port = config.Default().Server.Port
}
s := &server{
logger: logger,
@@ -97,6 +110,7 @@ func New(opts Options) http.Handler {
auth: svc,
dataDir: opts.DataDir,
projectDir: opts.ProjectDir,
port: port,
backups: backups,
shutdown: opts.Shutdown,
display: opts.Display,
@@ -202,6 +216,11 @@ func (s *server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/system/session", s.read(auth.RoleAdmin, s.getSessionSettingsHandler))
mux.HandleFunc("PUT /api/system/session", s.write(auth.RoleAdmin, s.updateSessionSettingsHandler))
// 大屏控制页要用「当前设备的局域网地址」拼出平板能直接打开的链接(可点 + 复制 + 二维码)。
// 面板自己常常是从 127.0.0.1 打开的,浏览器端点算不出本机局域网 IP,只能由服务端枚举网卡。
// 与 /api/system/about 同档:都是机器级信息,guest 一律 403。
mux.HandleFunc("GET /api/system/network", s.read(auth.RoleRecorder, s.systemNetwork))
// 基础配置
mux.HandleFunc("GET /api/teams", s.public(s.listTeamsHandler))
mux.HandleFunc("POST /api/teams", s.write(auth.RoleRecorder, s.createTeamHandler))