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

+138 -3
View File
@@ -12,7 +12,15 @@ import { settingsApi } from '../components/system/systemApi'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
import { copyText, lanDisplayLinks } from './lanDisplay'
import {
copyText,
lanDisplayLinks,
lanEmptyNote,
manualAddressStorage,
parseManualAddress,
readManualAddresses,
writeManualAddresses,
} from './lanDisplay'
/**
* 页面选项直接取大屏那份清单(`@/display/types`):规范页名与中文标签只维护一处,
@@ -348,11 +356,61 @@ const lanGateReason = computed(() =>
auth.readOnly && !data.isMock ? '只有登录后的录入员 / 管理员能查看局域网地址,当前为只读访客。' : null,
)
const lanLinks = computed(() => (lanInfo.value === null ? [] : lanDisplayLinks(lanInfo.value)))
/**
* 手填的局域网地址(卡片最后的兜底)。
*
* 为什么要有:自动检测这条路在个别机器上就是给的(up 位读不到、枚举不出),或者给出的
* 网段不是现场要用的那张网卡。操作员从 ipconfig 抄一个 IP 进来,是唯一不依赖检测手段的办法。
*
* 存在 localStorage 里:现场是「填一次、整个赛事都用」,切个页面或刷新就丢会让人反复抄 ipconfig。
* 存储不可用(隐私模式 / 配额满)时只当没存上,这个功能本身不请求服务端,也就不该因为存储问题挂掉。
*
* 声明在 lanLinks 之前:那是个 computed,函数体里引用到手写地址列表,先有数据源再算结果。
*/
const manualIps = ref<string[]>(readManualAddresses(manualAddressStorage()))
const manualInput = ref('')
/** 就地红字:非法输入时告诉操作员哪里不对,而不是默默什么都不发生。 */
const manualError = ref<string | null>(null)
function addManualAddress(): void {
const result = parseManualAddress(manualInput.value, manualIps.value)
if (!result.ok) {
manualError.value = result.error
return
}
manualIps.value = [...manualIps.value, result.ip]
writeManualAddresses(manualAddressStorage(), manualIps.value)
manualInput.value = ''
manualError.value = null
}
/** 删除只影响手填列表:自动检测出来的地址不受影响(它们不是操作员加进来的)。 */
function removeManualAddress(ip: string): void {
manualIps.value = manualIps.value.filter((item) => item !== ip)
writeManualAddresses(manualAddressStorage(), manualIps.value)
}
const lanLinks = computed(() =>
lanInfo.value === null ? [] : lanDisplayLinks(lanInfo.value, manualIps.value),
)
/**
* 空态文案:逐条列出服务端检查过、但没被采用的网卡与原因(最多 5 条)。
*
* `lanInfo` 为 null 时不会有东西渲染这句话(模板在前面就分流了),这里给空串只是为了让
* 类型收敛,不引入第二种「读取中」的写法。
*/
const lanEmptyText = computed(() => (lanInfo.value === null ? '' : lanEmptyNote(lanInfo.value)))
/** 有地址但服务端没给端口:拼不出链接,这句话要和「没插网线」分开说。 */
const lanPortMissing = computed(() => lanInfo.value !== null && lanInfo.value.port <= 0)
/**
* 手填入口只在「能拼出链接」时给:端口或路径都没拿到时,填了也只会得到一条打不开的地址。
* 只读访客 / 示例数据模式不额外设限——这个功能纯在本地浏览器里跑,不请求服务端,也就没有权限问题。
*/
const lanManualEnabled = computed(() => lanInfo.value !== null && !lanPortMissing.value)
/**
* 第一个地址的小二维码。
*
@@ -927,6 +985,15 @@ async function send(
<div v-for="(item, index) in lanLinks" :key="item.ip" class="lan-row">
<div class="lan-line">
<a class="lan-link" :href="item.url" target="_blank" rel="noopener">{{ item.url }}</a>
<span v-if="item.manual" class="mx-badge mx-badge-neutral">手动</span>
<button
v-if="item.manual"
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
@click="removeManualAddress(item.ip)"
>
<MxIcon name="close" :size="14" /> 删除
</button>
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" @click="copyLanUrl(item.url)">
<MxIcon :name="copiedUrl === item.url ? 'check' : 'copy'" :size="14" />
{{ copiedUrl === item.url ? '已复制' : '复制' }}
@@ -940,10 +1007,46 @@ async function send(
<div class="lan-qr-code" v-html="lanQrSvg"></div>
<div class="lan-qr-note">手机扫码直接打开</div>
</div>
<!--
来源不常规的地址(up 位读不到 / 按默认路由推断)照常给链接与二维码——它们往往
真能用——只是说清这条是怎么来的:现场遇到「扫了打不开」时才知道换下一条,
而不是以为整台机器没网。提示文案由 lanDisplay 按 confidence 给,页面不自己判断。
-->
<p v-if="item.hint !== null" class="display-note lan-uncertain">{{ item.hint }}</p>
<!--
类别说明与来源说明是两条独立的提示:自动配置地址可能同时是「兜底捡来的」,
两条都要说清,谁也不能把谁顶掉。私网 / 全局地址没有这条。
-->
<p v-if="item.scopeHint !== null" class="display-note lan-uncertain">{{ item.scopeHint }}</p>
</div>
</template>
<p v-else class="display-note">未检测到局域网地址(可能是没插网线 / 只有回环)。</p>
<p v-else class="display-note">{{ lanEmptyText }}</p>
<!--
手填入口:自动检测为空、或给出的网段不是现场要用的那张网卡时的最后一条路。
它不请求服务端(只往本地 localStorage 里记一笔),所以只读访客与示例数据模式下
一样能用;只在拿得到端口 / 路径时才出现——否则填了也拼不出一条能打开的链接。
-->
<div v-if="lanManualEnabled" class="lan-manual">
<div class="lan-manual-title">手动添加地址</div>
<p class="lan-manual-note">自动检测不到时,把 ipconfig 里的 IPv4 地址填进来</p>
<div class="lan-manual-form">
<div class="mx-input-wrap" :class="{ 'mx-input-error': manualError !== null }">
<input
v-model="manualInput"
class="mx-input"
type="text"
placeholder="192.168.1.23"
aria-label="手动添加的 IP 地址"
@keyup.enter="addManualAddress"
@input="manualError = null"
/>
</div>
<button class="mx-btn mx-btn-secondary" type="button" @click="addManualAddress">添加</button>
</div>
<p v-if="manualError !== null" class="mx-helper mx-helper-error">{{ manualError }}</p>
</div>
<p class="hint">
地址由服务端下发的端口与路径拼成;平板上打开后不必登录,大屏本身是只读的。
@@ -1138,6 +1241,38 @@ async function send(
font-size: 12px;
color: var(--mx-t3);
}
/* 低置信地址的提示:贴在地址行下面,和普通说明拉开一点距离。 */
.lan-uncertain {
margin-top: 6px;
}
/* 手填入口:和上面的地址列表隔开一点,像一个独立的小工具。 */
.lan-manual {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--mx-border);
}
.lan-manual-title {
font-size: 13px;
font-weight: 600;
color: var(--mx-t2);
}
.lan-manual-note {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.6;
margin-top: 4px;
}
.lan-manual-form {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
/* 输入框占满剩余宽度:IP 不长,但窄栏里也得让人看清自己填了什么。 */
.lan-manual-form .mx-input-wrap {
flex: 1;
min-width: 0;
}
.textarea-wrap {
height: auto;
align-items: flex-start;
+261 -5
View File
@@ -1,4 +1,11 @@
import type { NetworkAddress, NetworkInfo } from '@/shared'
import type {
NetworkAddress,
NetworkAddressScope,
NetworkConfidence,
NetworkInfo,
NetworkSkipCode,
NetworkSkippedInterface,
} from '@/shared'
/**
* 「局域网大屏」卡片的纯逻辑。
@@ -13,6 +20,27 @@ export interface LanDisplayLink {
ip: string
/** 完整地址:`http://<ip>:<port><displayPath>`。 */
url: string
/** 地址来源(服务端的 confidence,手填的按 high 处理)。 */
confidence: NetworkConfidence
/** 是否是操作员手填的地址(卡片上要带「手动」徽标、可删除)。 */
manual: boolean
/**
* 这条地址要额外说明的一句话(low / route 才有,high 为 null)。
*
* 文案在这里算好、模板直接用:提示与来源的对应关系只维护一处,页面里不会出现
* 「某个分支忘了提示」或「两个分支都提示」这类只有肉眼才能发现的偏差。
*/
hint: string | null
/** 地址类别(服务端给的;老服务端没这个字段时为 null)。 */
scope: NetworkAddressScope | null
/**
* 类别说明:只有自动配置地址(link-local)才需要解释一句「本网段没有 DHCP」,
* 私网 / 全局地址什么都不说。
*
* 它与 hint 是**两条互不影响的提示**:一条说「这是什么地址」,一条说「怎么找到的」,
* 两个都可以有,页面上各占一行。
*/
scopeHint: string | null
}
/** 拼一条局域网大屏地址;IPv6 要加方括号,否则端口那一段会被当成地址的一部分。 */
@@ -22,14 +50,242 @@ export function displayUrl(address: NetworkAddress, network: NetworkInfo): strin
}
/**
* 把服务端返回的网络信息摊平成可点的链接。
* 把服务端返回的网络信息 + 操作员手填的地址摊平成可点的链接。
*
* 端口缺失(服务端异常 / 老版本接口)时给空数组:拼 `http://ip:0/display` 这种
* 「看着像链接、点开连不上」的东西比空态更糟。
* 「看着像链接、点开连不上」的东西比空态更糟。手填地址同样靠这个端口拼,所以一起落空。
*
* 低置信(`confidence: 'low'` / `'route'`)的地址**照常给链接**:它们只是来源不常规
* (网卡 up 位读不到 / 是靠默认路由推断的),地址本身往往真的能用;藏起来等于把现场
* 唯一能用的那一条也拿走。
*
* 手填的排在自动检测结果**前面**:现场手填就是因为他认定「自动出来的那几条不是我要的网段」,
* 他关心的那条理应第一眼就看到、二维码也出在它上面。同一个地址既手填又被自动检测到时只留
* 手填的那条(同一个链接出现两次,现场会以为是两个不同的东西)。
*/
export function lanDisplayLinks(network: NetworkInfo): LanDisplayLink[] {
export function lanDisplayLinks(network: NetworkInfo, manual: string[] = []): LanDisplayLink[] {
if (!Number.isFinite(network.port) || network.port <= 0) return []
return network.addresses.map((address) => ({ ip: address.ip, url: displayUrl(address, network) }))
const manualLinks = manual.map((ip) => manualLink(ip, network))
const manualSet = new Set(manual)
const autoLinks = network.addresses
.filter((address) => !manualSet.has(address.ip))
.map((address) => {
const confidence = address.confidence ?? 'high'
const scope = address.scope ?? null
return {
ip: address.ip,
url: displayUrl(address, network),
confidence,
manual: false,
hint: confidenceHint(confidence),
scope,
scopeHint: scopeHint(scope),
}
})
return [...manualLinks, ...autoLinks]
}
/**
* 手填地址也是普通地址:接口名留空(它不是从哪张网卡枚举出来的,写一个假网卡名只会误导)。
*
* confidence 记 high 只是形状上的统一(这个字段本是服务端给的来源判定,手填的地址压根没经过
* 服务端),真正区分手填的是 manual;也正因为它是操作员自己认定的地址,不给任何来源提示语。
* scope 同理留 null:不按 IP 前缀猜类别(猜错会给出与事实不符的说明),也就不给类别提示。
*/
function manualLink(ip: string, network: NetworkInfo): LanDisplayLink {
return {
ip,
url: displayUrl({ ip, interface: '', family: ip.includes(':') ? 'ipv6' : 'ipv4' }, network),
confidence: 'high',
manual: true,
hint: null,
scope: null,
scopeHint: null,
}
}
/**
* 来源提示:只有「不常规的来源」才需要解释一句,正常枚举出来的(high)什么都不说。
* 现场看到提示才知道这条打不开时该换下一条,而不是以为整台机器没网。
*/
export function confidenceHint(confidence: NetworkConfidence): string | null {
if (confidence === 'low') return LAN_LOW_CONFIDENCE_HINT
if (confidence === 'route') return LAN_ROUTE_CONFIDENCE_HINT
return null
}
/**
* 类别提示:只有自动配置地址要解释——它看着像「网络没配好」,实际在无 DHCP 的独立内网里
* 就是正常且唯一可用的地址,不说清现场会以为卡片又出错了。
*/
export function scopeHint(scope: NetworkAddressScope | null): string | null {
return scope === 'link-local' ? LAN_LINK_LOCAL_SCOPE_HINT : null
}
/**
* 跳过原因(服务端 `skipped[].code`)的中文说明。
*
* 服务端只下发机器可读的 code:同一件事在服务端启动日志里另有一份面向运维的措辞,
* 而这里面向现场操作员。表必须覆盖全部 code(类型上就是穷尽的,漏一个编译不过)。
*/
export const NETWORK_SKIP_REASON_LABELS: Record<NetworkSkipCode, string> = {
'no-address': '接口没有地址',
'only-ipv6': '只有 IPv6 地址',
'not-up': '接口未连接',
loopback: '回环接口',
}
/** 未知 code 的兜底:服务端比前端新时也要说人话,不能让原因变成空白。 */
export function skipReasonLabel(code: string): string {
return NETWORK_SKIP_REASON_LABELS[code as NetworkSkipCode] ?? `未知原因(${code})`
}
/** 一条被跳过的网卡:「以太网(接口未连接)」。 */
function skippedLabel(item: NetworkSkippedInterface): string {
return `${item.interface === '' ? '未知网卡' : item.interface}(${skipReasonLabel(item.code)})`
}
/** 卡片在没有可用地址时给的那句话(老服务端没有 skipped,只能沿用这句)。 */
export const LAN_EMPTY_FALLBACK = '未检测到局域网地址(可能是没插网线 / 只有回环)。'
/**
* 低置信地址旁的提示:链接与二维码照常给,但要说明这条是兜底来的——
* 现场遇到「扫了打不开」时才知道下一步该换哪一条,而不是以为整台机器没网。
*/
export const LAN_LOW_CONFIDENCE_HINT =
'网卡状态读不到(可能是驱动或虚拟网卡),地址本身可用,打不开就换下面一条'
/**
* 路由探测来的地址旁的提示:它不是从网卡枚举出来的,而是问操作系统「去局域网走哪块网卡」得到的。
* 这种地址通常就是对的(系统自己路由出来的源地址),但要先说清来源,现场打不开时才不会怀疑机器没网。
*/
export const LAN_ROUTE_CONFIDENCE_HINT =
'这条地址是按系统默认路由推断出来的(网卡枚举里没有它),地址本身可用,打不开就换下面一条'
/**
* 自动配置地址(链路本地)旁的提示。
*
* 这种地址看着像「网络没配好」(系统自配的、没有 DHCP),但在没有 DHCP 的独立内网里它正是
* 设备之间互访用的地址——现场平板就是靠它打开的。不解释一句,现场会以为卡片又出错了,
* 或者把它当成「不能用」的地址去手填一个更糟的。
*/
export const LAN_LINK_LOCAL_SCOPE_HINT = '自动配置地址:本网段没有 DHCP,同网段设备可以互相访问'
/** 空态里最多列出几张网卡,其余的折叠成「等共 N 块网卡」。 */
export const LAN_SKIP_LIST_LIMIT = 5
/**
* 空态文案:把服务端「检查过、但没被采用」的网卡逐条列出来。
*
* 原来只有一句「未检测到局域网地址(可能是没插网线 / 只有回环)」,而真正的原因可能
* 是「只有 IPv6」「只有 169.254」「接口没置 up」——现场拿着那句话无从下手。
* 改成逐条列原因后,主人一眼就能看出该修哪张网卡。
* 服务端没给 skipped(老版本)时退回原来那句,行为与升级前完全一致。
*
* 网卡多的机器(虚拟网卡几十张)上原因会排成长长一串,卡片放不下也没人看:只列前 5 张,
* 其余折叠成「等共 N 块网卡」——数字本身就是线索(比如 30 张说明这台机器上虚拟网卡很多)。
*/
export function lanEmptyNote(network: NetworkInfo): string {
const skipped = network.skipped ?? []
if (skipped.length === 0) return LAN_EMPTY_FALLBACK
const shown = skipped.slice(0, LAN_SKIP_LIST_LIMIT).map(skippedLabel)
const folded = skipped.length > shown.length ? `等共 ${skipped.length} 块网卡` : ''
return `没找到可用的局域网地址。已检查:${shown.join('、')}${folded}。`
}
/** 手填地址在 localStorage 里的键。带前缀,免得和同域下别的页面撞。 */
export const MANUAL_LAN_STORAGE_KEY = 'scoreboard.display.manualLanAddresses'
/** 手填地址最多存几个:现场可能存着旧的;没有上限的话这条列表会一直长下去。 */
export const MAX_MANUAL_ADDRESSES = 10
/** localStorage 的最小接口;注入假实现后,node 里也能把存取与脏数据这两条路走一遍。 */
export interface ManualAddressStorage {
getItem(key: string): string | null
setItem(key: string, value: string): void
}
/** 浏览器的 localStorage;隐私模式 / 禁用存储时**读属性本身就抛**,所以整段包在 try 里。 */
export function manualAddressStorage(): ManualAddressStorage | null {
try {
return (globalThis as { localStorage?: ManualAddressStorage }).localStorage ?? null
} catch {
return null
}
}
/** 读出持久化的手填地址:坏数据(不是数组 / 不是字符串 / 不是 IP / 重复)一律不认。 */
export function readManualAddresses(storage: ManualAddressStorage | null): string[] {
if (storage === null) return []
let raw: string | null
try {
raw = storage.getItem(MANUAL_LAN_STORAGE_KEY)
} catch {
return []
}
if (raw === null) return []
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
return []
}
if (!Array.isArray(parsed)) return []
const out: string[] = []
for (const item of parsed) {
if (typeof item !== 'string') continue
// 存下来的可能是旧版本写的、或人手工改过的内容:每条都按同一套规则重新校验,
// 免得一个坏地址变成一条点不开的链接,或者把整个列表带崩。
const result = parseManualAddress(item, out)
if (result.ok) out.push(result.ip)
}
return out.slice(0, MAX_MANUAL_ADDRESSES)
}
/** 写回手填地址;写不进去(配额满 / 隐私模式)只当没存上,绝不让它影响页面。 */
export function writeManualAddresses(storage: ManualAddressStorage | null, ips: string[]): void {
if (storage === null) return
try {
storage.setItem(MANUAL_LAN_STORAGE_KEY, JSON.stringify(ips.slice(0, MAX_MANUAL_ADDRESSES)))
} catch {
// 存不下就存不下:地址这一会儿还在页面上,刷新丢了也只是要再填一次。
}
}
/** 校验结果:要么给出规整后的地址,要么给出就地红字要显示的那句话。 */
export type ManualAddressParse = { ok: true; ip: string } | { ok: false; error: string }
/**
* 校验操作员手输的地址(从 ipconfig 抄来的那串)。
*
* 规则:首尾空格先去掉——从 ipconfig 复制粘贴时几乎必然带上;中间还有空白就是抄错了。
* 只认 IPv4 点分十进制与 IPv6 字面量(IPv6 要允许:现场也可能用双栈地址,拼 URL 加方括号
* 的逻辑 displayUrl 里已经有了)。不做回环 / link-local 过滤:手填的本来就是「操作员比我清楚」
* 那一条,他真填了 127.0.0.1 也是他自己的判断,卡片不该替他改。
*/
export function parseManualAddress(raw: string, existing: string[] = []): ManualAddressParse {
const value = raw.trim()
if (value === '') return { ok: false, error: '请先填一个 IP 地址' }
if (/\s/.test(value)) return { ok: false, error: '地址里不能有空格,请重新复制一次 ipconfig 里的地址' }
const ip = canonicalIp(value)
if (ip === null) return { ok: false, error: '这不像一个 IP 地址,请填 ipconfig 里的 IPv4 地址' }
if (existing.includes(ip)) return { ok: false, error: '这个地址已经在列表里了' }
return { ok: true, ip }
}
/** 认得出就给出规整形式(IPv6 统一成小写压缩写法,去重才靠得住),认不出回 null。 */
function canonicalIp(value: string): string | null {
const parts = value.split('.')
if (parts.length === 4 && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)) return value
if (!value.includes(':') || value.includes('%')) return null
try {
// 借 URL 解析器做 IPv6 校验:手写正则太难覆盖压缩写法(:: 的位置、内嵌 IPv4 等),
// 而浏览器/node 的 URL 只接受合法的 IPv6 字面量,顺带把大写与压缩形式规整掉。
const host = new URL(`http://[${value}]`).hostname
return host === '' ? null : host.slice(1, -1)
} catch {
return null
}
}
/** 复制用到的宿主能力;全部可注入,这样在 node 里也能把回退路径走一遍。 */
+79 -4
View File
@@ -30,12 +30,87 @@ const { ApiError, adminApi } = await import('../api/client')
const PAGE = readFileSync(new URL('../pages/DisplayPage.vue', import.meta.url), 'utf8')
test('卡片文案齐全:标题、一句话说明、复制反馈、扫码说明、空态', () => {
test('卡片文案齐全:标题、一句话说明、复制反馈、扫码说明、来源提示', () => {
assert.match(PAGE, /局域网大屏/)
assert.match(PAGE, /在别的设备浏览器打开下面的地址,就是一块大屏/)
assert.match(PAGE, /手机扫码直接打开/)
assert.match(PAGE, /已复制/)
assert.match(PAGE, /未检测到局域网地址(可能是没插网线 \/ 只有回环)/)
// 提示原文钉在 lanDisplay.ts(只有一处),页面只负责渲染 lanDisplayLinks 给的那句话:
// 页面自己判断 confidence 就会漏掉第三种来源(route)。
assert.match(PAGE, /\{\{ item\.hint \}\}/)
assert.doesNotMatch(PAGE, /LAN_LOW_CONFIDENCE_HINT|LAN_ROUTE_CONFIDENCE_HINT/)
})
test('空态文案由 lanEmptyNote 生成(逐条列原因),不写死在页面里', () => {
assert.match(PAGE, /from '\.\/lanDisplay'/)
assert.match(PAGE, /lanEmptyNote,/)
assert.match(PAGE, /<p v-else class="display-note">\{\{ lanEmptyText \}\}<\/p>/)
assert.match(PAGE, /const lanEmptyText = computed\(/)
// 老文案属于 lanDisplay.ts(老服务端分支),页面里不该再抄一份。
assert.doesNotMatch(PAGE, /class="display-note">未检测到局域网地址/)
})
test('来源提示只挂在有提示的那条地址旁(high 一句都不说)', () => {
// 提示挂在 item.hint 上,而 hint 由 lanDisplay 按来源给:high → null,low / route → 各自的句子。
assert.match(PAGE, /<p v-if="item\.hint !== null" class="display-note lan-uncertain">/)
// 链接与二维码不受来源影响:up 位读不到 / 路由推断出来的地址照常渲染。
assert.match(PAGE, /<a class="lan-link" :href="item\.url" target="_blank" rel="noopener">/)
assert.match(PAGE, /v-if="index === 0 && lanQrSvg !== null"/)
})
test('类别说明:只有 scopeHint 不为空的那条地址才出现(私网 / 全局没有)', () => {
assert.match(
PAGE,
/<p v-if="item\.scopeHint !== null" class="display-note lan-uncertain">\{\{ item\.scopeHint \}\}<\/p>/,
)
// 页面不自己判类别、更不按前缀猜:说明句由 lanDisplay 按服务端给的 scope 算好。
assert.doesNotMatch(PAGE, /link-local|169\.254/)
})
test('来源提示与类别说明是两条独立的行,不能互相顶掉', () => {
// 两条都必须是 v-if:写成 v-if + v-else 的话,一条地址明明两个提示都该给,却只会显示一个。
const hintIndex = PAGE.indexOf('{{ item.hint }}')
const scopeIndex = PAGE.indexOf('{{ item.scopeHint }}')
assert.ok(hintIndex > 0 && scopeIndex > hintIndex, '两条提示都要渲染,且各占一行')
assert.doesNotMatch(PAGE, /v-else[\s\S]{0,200}?\{\{ item\.scopeHint \}\}/)
})
test('手填入口:标题、副说明、输入框、添加按钮、就地红字', () => {
assert.match(PAGE, /手动添加地址/)
assert.match(PAGE, /自动检测不到时,把 ipconfig 里的 IPv4 地址填进来/)
assert.match(PAGE, /v-model="manualInput"/)
assert.match(PAGE, /@keyup\.enter="addManualAddress"/)
assert.match(PAGE, />添加<\/button>/)
// 非法输入必须当场红字说明,而不是点了没反应。
assert.match(PAGE, /v-if="manualError !== null" class="mx-helper mx-helper-error"/)
// 输入框本身也要标红:现场一眼看出是这一格填错了。
assert.match(PAGE, /:class="\{ 'mx-input-error': manualError !== null \}"/)
// 只在拼得出链接时给入口(端口没下发时填了也白填)。
assert.match(PAGE, /v-if="lanManualEnabled" class="lan-manual"/)
})
test('手填的地址带「手动」徽标、可删除,并写进 localStorage', () => {
assert.match(PAGE, /v-if="item\.manual" class="mx-badge mx-badge-neutral">手动</)
assert.match(PAGE, /@click="removeManualAddress\(item\.ip\)"/)
assert.match(PAGE, /const manualIps = ref<string\[\]>\(readManualAddresses\(manualAddressStorage\(\)\)\)/)
assert.match(PAGE, /writeManualAddresses\(manualAddressStorage\(\), manualIps\.value\)/)
// 删除只动手填列表;自动检测出来的地址不写回存储、也不受影响。
assert.match(PAGE, /manualIps\.value\.filter\(\(item\) => item !== ip\)/)
})
test('添加这条路真的会校验并持久化(只看「有 writeManualAddresses」会被别处的调用蒙混过去)', () => {
const addFn = /function addManualAddress\(\): void \{[\s\S]*?\n\}/.exec(PAGE)?.[0] ?? ''
assert.notEqual(addFn, '', '找不到 addManualAddress')
assert.match(addFn, /parseManualAddress\(manualInput\.value, manualIps\.value\)/)
assert.match(addFn, /writeManualAddresses\(manualAddressStorage\(\), manualIps\.value\)/)
// 校验不过必须当场返回,不能把非法值塞进列表。
assert.match(addFn, /if \(!result\.ok\) \{[\s\S]*?return/)
})
test('手填入口不依赖权限与示例数据:没有 gate / isMock 判断', () => {
const manualBlock = /<div v-if="lanManualEnabled" class="lan-manual">[\s\S]*?<\/div>\s*<\/div>/.exec(PAGE)?.[0] ?? ''
assert.notEqual(manualBlock, '')
assert.doesNotMatch(manualBlock, /lanGateReason|isMock|readOnly/)
})
test('链接本身就是可点的新窗口链接', () => {
@@ -43,8 +118,8 @@ test('链接本身就是可点的新窗口链接', () => {
})
test('页面的地址只来自 lanDisplayLinks(),不写死端口或路径', () => {
assert.match(PAGE, /import \{ copyText, lanDisplayLinks \} from '\.\/lanDisplay'/)
assert.match(PAGE, /lanDisplayLinks\(lanInfo\.value\)/)
assert.match(PAGE, /from '\.\/lanDisplay'/)
assert.match(PAGE, /lanDisplayLinks\(lanInfo\.value, manualIps\.value\)/)
// 19540 一旦出现在页面里,就说明有人在拼地址时替服务端做了决定。
assert.doesNotMatch(PAGE, /19540/)
})
+389 -2
View File
@@ -1,8 +1,28 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import type { NetworkInfo } from '@/shared'
import { copyText, displayUrl, lanDisplayLinks } from '../pages/lanDisplay'
import type { NetworkConfidence, NetworkInfo, NetworkSkipCode } from '@/shared'
import {
confidenceHint,
copyText,
displayUrl,
LAN_EMPTY_FALLBACK,
LAN_LINK_LOCAL_SCOPE_HINT,
LAN_LOW_CONFIDENCE_HINT,
LAN_ROUTE_CONFIDENCE_HINT,
LAN_SKIP_LIST_LIMIT,
lanDisplayLinks,
lanEmptyNote,
MANUAL_LAN_STORAGE_KEY,
MAX_MANUAL_ADDRESSES,
NETWORK_SKIP_REASON_LABELS,
parseManualAddress,
readManualAddresses,
scopeHint,
skipReasonLabel,
writeManualAddresses,
type ManualAddressStorage,
} from '../pages/lanDisplay'
/**
* 「局域网大屏」卡片的两条硬要求:
@@ -55,6 +75,373 @@ test('端口为 0 时给空数组,不拼 http://ip:0/display 这种点不开
assert.deepEqual(lanDisplayLinks({ ...NETWORK, port: 0 }), [])
})
test('低置信地址照常生成链接(藏起来等于把现场唯一能用的那条也拿走),并带上 confidence', () => {
const info: NetworkInfo = {
...NETWORK,
addresses: [
{ ip: '192.168.1.23', interface: '以太网', family: 'ipv4', confidence: 'low' },
{ ip: '10.0.8.7', interface: 'wlan0', family: 'ipv4' },
],
}
assert.deepEqual(lanDisplayLinks(info), [
{
ip: '192.168.1.23',
url: 'http://192.168.1.23:19540/display',
confidence: 'low',
manual: false,
hint: LAN_LOW_CONFIDENCE_HINT,
scope: null,
scopeHint: null,
},
// 老服务端没有 confidence / scope 字段:按 high,且不加任何提示。
{
ip: '10.0.8.7',
url: 'http://10.0.8.7:19540/display',
confidence: 'high',
manual: false,
hint: null,
scope: null,
scopeHint: null,
},
])
})
test('路由推断出来的地址标 route,并给一句自己的提示', () => {
const info: NetworkInfo = {
...NETWORK,
addresses: [{ ip: '192.168.5.9', interface: '默认路由', family: 'ipv4', confidence: 'route', scope: 'private' }],
}
assert.deepEqual(lanDisplayLinks(info), [
{
ip: '192.168.5.9',
url: 'http://192.168.5.9:19540/display',
confidence: 'route',
manual: false,
hint: LAN_ROUTE_CONFIDENCE_HINT,
scope: 'private',
scopeHint: null,
},
])
})
test('来源提示:只有不常规的来源才说话,high 一句都不多说', () => {
assert.equal(confidenceHint('high'), null)
assert.equal(confidenceHint('low'), LAN_LOW_CONFIDENCE_HINT)
assert.equal(confidenceHint('route'), LAN_ROUTE_CONFIDENCE_HINT)
// 三条文案必须互不相同:route 的地址被说成「网卡状态读不到」会把人引到完全错的方向。
assert.notEqual(LAN_ROUTE_CONFIDENCE_HINT, LAN_LOW_CONFIDENCE_HINT)
assert.equal(LAN_ROUTE_CONFIDENCE_HINT, '这条地址是按系统默认路由推断出来的(网卡枚举里没有它),地址本身可用,打不开就换下面一条')
})
// ── 地址类别(scope)────────────────────────────────────────────────────
test('自动配置地址给一句说明,私网 / 全局 / 未知都不给', () => {
assert.equal(LAN_LINK_LOCAL_SCOPE_HINT, '自动配置地址:本网段没有 DHCP,同网段设备可以互相访问')
assert.equal(scopeHint('link-local'), LAN_LINK_LOCAL_SCOPE_HINT)
assert.equal(scopeHint('private'), null)
assert.equal(scopeHint('global'), null)
assert.equal(scopeHint(null), null)
})
test('主人现场那条地址:自动配置地址照常给链接、二维码位置在第一条,并带上说明', () => {
const info: NetworkInfo = {
...NETWORK,
addresses: [
{ ip: '192.168.1.23', interface: 'WLAN', family: 'ipv4', confidence: 'high', scope: 'private' },
{ ip: '169.254.79.190', interface: '以太网', family: 'ipv4', confidence: 'high', scope: 'link-local' },
],
}
const links = lanDisplayLinks(info)
assert.deepEqual(
links.map((link) => [link.ip, link.scope, link.scopeHint]),
[
['192.168.1.23', 'private', null],
// 私网在前、自动配置在后;后者照常是可点的链接,只是多一句说明。
['169.254.79.190', 'link-local', LAN_LINK_LOCAL_SCOPE_HINT],
],
)
assert.equal(links[1]!.url, 'http://169.254.79.190:19540/display')
})
test('来源提示与类别提示互不顶掉:同一条地址两条都给', () => {
const info: NetworkInfo = {
...NETWORK,
addresses: [{ ip: '169.254.79.190', interface: '以太网', family: 'ipv4', confidence: 'low', scope: 'link-local' }],
}
const link = lanDisplayLinks(info)[0]!
assert.equal(link.hint, LAN_LOW_CONFIDENCE_HINT)
assert.equal(link.scopeHint, LAN_LINK_LOCAL_SCOPE_HINT)
})
test('scope 认不出(老服务端)时不给类别说明,也不按前缀自己猜', () => {
const info: NetworkInfo = {
...NETWORK,
// 老服务端不返回 scope:即使地址恰好长得像自动配置地址,也不加说明(分类是服务端的知识)。
addresses: [{ ip: '169.254.79.190', interface: '以太网', family: 'ipv4', confidence: 'high' }],
}
const link = lanDisplayLinks(info)[0]!
assert.equal(link.scope, null)
assert.equal(link.scopeHint, null)
})
test('低置信提示的文案只有一处', () => {
assert.equal(LAN_LOW_CONFIDENCE_HINT, '网卡状态读不到(可能是驱动或虚拟网卡),地址本身可用,打不开就换下面一条')
})
test('空态逐条列出被跳过的网卡与原因', () => {
const info: NetworkInfo = {
...NETWORK,
addresses: [],
skipped: [
{ interface: '以太网', code: 'not-up', addresses: ['10.0.0.10/24'] },
{ interface: 'WLAN', code: 'only-ipv6', addresses: ['fe80::1/64'] },
],
}
assert.equal(
lanEmptyNote(info),
'没找到可用的局域网地址。已检查:以太网(接口未连接)、WLAN(只有 IPv6 地址)。',
)
})
test('空态:服务端没给 skipped(老版本)时退回原来那句话', () => {
assert.equal(lanEmptyNote({ ...NETWORK, addresses: [] }), LAN_EMPTY_FALLBACK)
assert.equal(lanEmptyNote({ ...NETWORK, addresses: [], skipped: [] }), LAN_EMPTY_FALLBACK)
})
test('空态:网卡名缺失时也不能拼出「()」', () => {
const info: NetworkInfo = {
...NETWORK,
addresses: [],
skipped: [{ interface: '', code: 'no-address', addresses: [] }],
}
assert.equal(lanEmptyNote(info), '没找到可用的局域网地址。已检查:未知网卡(接口没有地址)。')
})
test('原因映射覆盖全部 code(一个都不能漏)与未知 code 的兜底', () => {
// 集合里没有 link-local:自动配置地址现在是候选地址,不再是「被跳过」的理由。
const codes: NetworkSkipCode[] = ['no-address', 'only-ipv6', 'not-up', 'loopback']
// 表本身必须与 code 集合完全对应:多一个少一个都在这里红灯。
assert.deepEqual(Object.keys(NETWORK_SKIP_REASON_LABELS).sort(), [...codes].sort())
for (const code of codes) {
const label = skipReasonLabel(code)
assert.equal(label, NETWORK_SKIP_REASON_LABELS[code])
assert.notEqual(label.trim(), '')
// 未知 code 之外不能落到兜底话术上,否则「漏了 code」会被兜底悄悄盖住。
assert.doesNotMatch(label, /未知原因/)
}
// 服务端比前端新:认不出的 code 也要说人话,不能留空白。
assert.equal(skipReasonLabel('brand-new-code'), '未知原因(brand-new-code)')
// 链路本地不再是跳过原因:旧服务端万一还发这个 code,也不能显示成「地址不能用」那一类说法。
assert.notEqual(skipReasonLabel('link-local'), '只有自动配置地址(169.254.x)')
})
test('每条地址的置信度只可能是 high / low / route', () => {
const info: NetworkInfo = {
...NETWORK,
addresses: [{ ip: '10.0.0.4', interface: 'ZeroTier', family: 'ipv4' }],
}
const confidence: NetworkConfidence = lanDisplayLinks(info)[0]!.confidence
assert.equal(confidence, 'high')
})
// ── 空态的折叠 ────────────────────────────────────────────────────────────
test('空态最多列 5 张网卡,其余的折叠成「等共 N 块网卡」', () => {
const many: NetworkInfo = {
...NETWORK,
addresses: [],
skipped: Array.from({ length: 30 }, (_, index) => ({
interface: `vEthernet ${index + 1}`,
code: 'no-address' as const,
addresses: [],
})),
}
const note = lanEmptyNote(many)
// 只有前 5 张带原因,第 6 张的接口名不能出现(否则等于没折叠)。
for (let index = 1; index <= LAN_SKIP_LIST_LIMIT; index += 1) {
assert.match(note, new RegExp(`vEthernet ${index}(接口没有地址)`))
}
assert.doesNotMatch(note, /vEthernet 6(/)
assert.match(note, /等共 30 块网卡。$/)
assert.equal(
note,
'没找到可用的局域网地址。已检查:vEthernet 1(接口没有地址)、vEthernet 2(接口没有地址)、' +
'vEthernet 3(接口没有地址)、vEthernet 4(接口没有地址)、vEthernet 5(接口没有地址)等共 30 块网卡。',
)
})
test('空态:不超过 5 张时不折叠(不出现「等共」)', () => {
const info: NetworkInfo = {
...NETWORK,
addresses: [],
skipped: Array.from({ length: LAN_SKIP_LIST_LIMIT }, (_, index) => ({
interface: `网卡${index + 1}`,
code: 'loopback' as const,
addresses: [],
})),
}
assert.doesNotMatch(lanEmptyNote(info), /等共/)
// 恰好 5 张也要一张不漏地列出来。
assert.match(lanEmptyNote(info), /网卡5(回环接口)。$/)
})
// ── 手填地址 ──────────────────────────────────────────────────────────────
test('手填地址:合法 IP 排在自动检测结果前面,带「手动」标记与链接', () => {
const links = lanDisplayLinks(NETWORK, ['192.168.9.9'])
assert.deepEqual(links, [
{
ip: '192.168.9.9',
url: 'http://192.168.9.9:19540/display',
confidence: 'high',
manual: true,
// 手填的是操作员自己的判断,不需要额外解释来源;类别也不猜(scope 为 null)。
hint: null,
scope: null,
scopeHint: null,
},
{
ip: '192.168.1.23',
url: 'http://192.168.1.23:19540/display',
confidence: 'high',
manual: false,
hint: null,
scope: null,
scopeHint: null,
},
{
ip: '10.0.8.7',
url: 'http://10.0.8.7:19540/display',
confidence: 'high',
manual: false,
hint: null,
scope: null,
scopeHint: null,
},
])
})
test('手填地址:多个按添加顺序排在前面,IPv6 拼 URL 带方括号', () => {
const links = lanDisplayLinks(NETWORK, ['10.1.1.1', 'fe80::1'])
assert.deepEqual(
links.map((link) => link.ip),
['10.1.1.1', 'fe80::1', '192.168.1.23', '10.0.8.7'],
)
assert.equal(links[1]!.url, 'http://[fe80::1]:19540/display')
assert.equal(links[1]!.manual, true)
})
test('手填地址:与自动检测重复时只留手填的那条(同一个链接不出现两次)', () => {
const links = lanDisplayLinks(NETWORK, ['192.168.1.23'])
assert.deepEqual(
links.map((link) => [link.ip, link.manual]),
[
['192.168.1.23', true],
['10.0.8.7', false],
],
)
})
test('手填地址:端口没下发时连手填的也拼不出链接(不留半条能点的地址)', () => {
assert.deepEqual(lanDisplayLinks({ ...NETWORK, port: 0 }, ['192.168.9.9']), [])
})
test('手填校验:合法 IPv4 / IPv6 收下,非法输入给红字且不添加', () => {
assert.deepEqual(parseManualAddress('192.168.1.23'), { ok: true, ip: '192.168.1.23' })
// 从 ipconfig 复制粘贴常带首尾空格:去掉后就合法。
assert.deepEqual(parseManualAddress(' 192.168.1.23 '), { ok: true, ip: '192.168.1.23' })
// IPv6 允许,并统一成小写压缩写法(去重才靠得住)。
assert.deepEqual(parseManualAddress('FE80::0001'), { ok: true, ip: 'fe80::1' })
assert.deepEqual(parseManualAddress('::1'), { ok: true, ip: '::1' })
const bad: Array<[string, RegExp]> = [
['', /请先填一个 IP 地址/],
[' ', /请先填一个 IP 地址/],
['192.168.1.23 10.0.0.1', /不能有空格/],
['192.168.1.2 3', /不能有空格/],
['192.168.1', /不像一个 IP 地址/],
['999.168.1.1', /不像一个 IP 地址/],
['192.168.1.256', /不像一个 IP 地址/],
['abc', /不像一个 IP 地址/],
['not:an:ip', /不像一个 IP 地址/],
['fe80::1%eth0', /不像一个 IP 地址/],
]
for (const [raw, message] of bad) {
const result = parseManualAddress(raw)
assert.equal(result.ok, false, `${raw} 不该通过校验`)
if (!result.ok) assert.match(result.error, message)
}
})
test('手填校验:重复的地址不添加,并说清原因', () => {
const result = parseManualAddress('10.0.0.4', ['192.168.1.23', '10.0.0.4'])
assert.equal(result.ok, false)
if (!result.ok) assert.match(result.error, /已经在列表里/)
})
test('手填持久化:localStorage 往返,坏数据一律不认', () => {
const store = fakeStorage()
assert.deepEqual(readManualAddresses(store), [])
writeManualAddresses(store, ['192.168.9.9', '10.1.1.1'])
assert.equal(store.value(MANUAL_LAN_STORAGE_KEY), '["192.168.9.9","10.1.1.1"]')
assert.deepEqual(readManualAddresses(store), ['192.168.9.9', '10.1.1.1'])
// 脏数据:不是数组 / 元素不是字符串 / 不是 IP / 重复 —— 全部丢掉,不能让页面崩。
store.setValue(MANUAL_LAN_STORAGE_KEY, '{"not":"an array"}')
assert.deepEqual(readManualAddresses(store), [])
store.setValue(MANUAL_LAN_STORAGE_KEY, '{ 这不是 json')
assert.deepEqual(readManualAddresses(store), [])
store.setValue(MANUAL_LAN_STORAGE_KEY, JSON.stringify(['192.168.1.23', 42, 'abc', ' ', '192.168.1.23']))
assert.deepEqual(readManualAddresses(store), ['192.168.1.23'])
// 超量的旧数据要在读的时候就被截断,不然键里会一直堆着几十条。
store.setValue(
MANUAL_LAN_STORAGE_KEY,
JSON.stringify(Array.from({ length: MAX_MANUAL_ADDRESSES + 5 }, (_, index) => `10.0.0.${index + 1}`)),
)
assert.equal(readManualAddresses(store).length, MAX_MANUAL_ADDRESSES)
})
test('手填持久化:存储不可用(隐私模式 / 配额满)时静默降级,不抛异常', () => {
assert.deepEqual(readManualAddresses(null), [])
writeManualAddresses(null, ['192.168.1.23'])
const throwing: ManualAddressStorage = {
getItem: () => {
throw new Error('SecurityError')
},
setItem: () => {
throw new Error('QuotaExceededError')
},
}
assert.deepEqual(readManualAddresses(throwing), [])
writeManualAddresses(throwing, ['192.168.1.23'])
})
/** 内存版 localStorage:node 里没有浏览器存储,存取两条路都要能测。 */
function fakeStorage(): ManualAddressStorage & { value(key: string): string | null; setValue(key: string, raw: string): void } {
const data = new Map<string, string>()
return {
getItem: (key) => data.get(key) ?? null,
setItem: (key, value) => {
data.set(key, value)
},
value: (key) => data.get(key) ?? null,
setValue: (key, raw) => {
data.set(key, raw)
},
}
}
test('复制:navigator.clipboard 不存在时走到 execCommand 回退', async () => {
const seen: string[] = []
const ok = await copyText('http://192.168.1.23:19540/display', {