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:
1 parent
9ef68c2589
commit
c97d3150c9
18 files changed
+2425
-79
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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 里也能把回退路径走一遍。 */
|
||||
|
||||
Reference in new issue
Block a user