fix(admin): 局域网大屏卡片按「地址类别」判定 + 三层兜底 + 手填入口(dev_0.5.0)

## 判断失误的修正(主人现场实测出来的)
主人现场是 **169.254 自动配置网段**(没有 DHCP,设备靠链路本地地址互访),
平板实测能访问 `http://169.254.79.190:19540/display`;而卡片一开始把链路本地地址
**排除**了,于是显示「未检测到局域网地址」。
根因:`usableLanIPv4` 排除了 `IsLinkLocalUnicast()`,理由写的是「169.254 说明没拿到 IP」
——那是**对网络健不健康的猜测**,不是**地址能不能用的事实**。

现改为按**地址类别**判定(`ipv4Scope`,全部用标准库谓词,**代码里没有任何网段字面量**):
只排除「作为对端访问目标没有意义」的四类(回环 / 未指定 / 组播 / 广播),
链路本地与私网、全局一样进候选,只**排在最后**并带说明
「自动配置地址:本网段没有 DHCP,同网段设备可以互相访问」。
新增**反硬编码护栏用例**:函数体内不得出现 `x.x.x.x` 字面量,且必须用满 6 个谓词。
顺带修掉新用例抓出的真 bug:`ipv4Scope(nil)` 曾落进 default 被当成「可用的 global」,
而 IPv6 的 `To4()` 正是 nil → 「只有 IPv6 的网卡」会被误判成有可用地址。

## 三层兜底(离线现场必须拿得到地址)
1. **严格轮**:接口 up 且非回环;
2. **宽松轮**:严格轮为空时不再要求 up 位(某些驱动/虚拟网卡读不到),命中标 `confidence: low`;
3. **默认路由探测**:两轮皆空时用 `net.Dial("udp4", …)` 问出本机源地址
   (UDP connect 只查路由表、不握手不发包,离线安全;TCP 会等 SYN 超时,不能用),标 `confidence: route`。
接口同时回 `scope`(private/global/link-local)与 `skipped`(被跳过的网卡 + 原因,
code 收敛为 no-address / only-ipv6 / not-up / loopback——link-local 不再是「被跳过」的理由)。

## 手填入口(最后一道保险)
自动检测不到、或不是现场要用的网段时,操作员把 `ipconfig` 里的地址填进卡片,
立刻生成链接 + 二维码;排在第一条、带「手动」徽标、可删除、localStorage 持久化、
四条就地校验文案(空/含空格/不像 IP/重复),IPv6 也支持。

## 自检工具
`debug/诊断/局域网地址自检.exe` + 说明:只读、不写文件、不需管理员,
打印系统给 Go 看的网卡列表(含类别)、默认路由探测结果与可用大屏地址。
(正是它把现场定性成 169.254 网段的。)

## 真浏览器验证(四场景,非源码断言)
链接本地地址出链接+二维码+说明句 / 空态列出被跳过网卡并折叠(5 条 + 等共 N 块)/
low 与 route 两种来源提示各占一行互不顶掉 / 手填地址排第一并刷新不丢 —— 全部通过,0 页面错误。

## 文档
使用手册 §9、部署指南 7.5/7.9、上线检查清单(**防火墙放行入站 19540** 与
「另一台设备能打开局域网大屏」两条新增)、常见问题 Q101、升级说明 dev_0.5.0;
devdocs 05/07/13 同步(含这次误判的来龙去脉与「Get-NetAdapter 显示 Up ≠ 有 IP」)。
**没有数据迁移**,旧赛事包照常导入。

自检:服务端 13 包 ×2;前端 vue-tsc + 680 例 ×2 + build;Console 20 文件 + 68 例。
This commit is contained in:
AskaEth committed 2026-09-12 18:30:46 +08:00
1 parent 9ef68c2589
commit c97d3150c9
18 files changed
+2425 -79

No files matched your search

+32
View File
@@ -27,7 +27,9 @@ import type {
ImportConflictDecision,
LiveEvents,
NetworkAddress,
NetworkAddressScope,
NetworkInfo,
NetworkSkippedInterface,
Page,
PageQuery,
PointAdjustment,
@@ -721,6 +723,30 @@ function readNetworkAddress(value: unknown): NetworkAddress | null {
ip,
interface: readString(value, 'interface') ?? '',
family: readString(value, 'family') ?? 'ipv4',
// 只认服务端契约里的 low / route 两个「需要额外提示」的值:老服务端没有这个字段
// (它的地址本来就走严格轮),值也不认识时按 high 处理——凭一个没见过的值在卡片上
// 多印一句提示,是凭空造出来的噪声。
confidence: value.confidence === 'low' || value.confidence === 'route' ? value.confidence : 'high',
// 类别同样只认契约里的三个值;认不出(老服务端)时留 undefined,卡片就不加类别说明。
// 绝不按 IP 前缀在这里补一个:分类是服务端的知识,前端猜出来的值会和它打架。
scope: readScope(value.scope),
}
}
function readScope(value: unknown): NetworkAddressScope | undefined {
return value === 'private' || value === 'global' || value === 'link-local' ? value : undefined
}
/** 归一化 `skipped[]` 的一条:没有 code 的行解释不了任何事,直接丢掉。 */
function readNetworkSkipped(value: unknown): NetworkSkippedInterface | null {
if (!isRecord(value)) return null
const code = readString(value, 'code')
if (code === null || code === '') return null
const rawAddresses = Array.isArray(value['addresses']) ? value['addresses'] : []
return {
interface: readString(value, 'interface') ?? '',
code,
addresses: rawAddresses.filter((item): item is string => typeof item === 'string'),
}
}
@@ -730,10 +756,13 @@ function readNetworkAddress(value: unknown): NetworkAddress | null {
* 端口/路径是拼地址的原料,前端不替服务端猜:缺端口就留 0,由页面判断「读不到可用地址」
* 并给说明——编一个默认端口出来会得到一条看着对、实际打不开的链接。
* addresses 不是数组时按「没检测到地址」处理,交给页面显示空态。
* skipped 不是数组时归一化成空数组(等价于「没有可解释的网卡」):老服务端压根不返回这个
* 字段,卡片据此退回原来那句空态文案,行为与升级前一致。
*/
function readNetworkInfo(value: unknown): NetworkInfo {
const port = firstNumberOrNull(isRecord(value) ? value['port'] : null)
const rawAddresses = isRecord(value) && Array.isArray(value['addresses']) ? value['addresses'] : []
const rawSkipped = isRecord(value) && Array.isArray(value['skipped']) ? value['skipped'] : []
return {
port: port !== null && port > 0 ? Math.floor(port) : 0,
displayPath: normalizePath(readString(value, 'displayPath'), '/display'),
@@ -741,6 +770,9 @@ function readNetworkInfo(value: unknown): NetworkInfo {
addresses: rawAddresses
.map(readNetworkAddress)
.filter((item): item is NetworkAddress => item !== null),
skipped: rawSkipped
.map(readNetworkSkipped)
.filter((item): item is NetworkSkippedInterface => item !== null),
}
}