fix(display,entry): 修大屏团体名次显示「-」与分项成绩第 3 栏缺第 1 名;发布改弹窗选口径;会话设备名与重命名;录分搜索框;版本 dev_0.5.15

本批七项(三处是现场报的 bug,四处是新功能):

① 大屏上「纯团体项目」(如团体 4x100 接力)的名次全是「-」
   根因不在算分:纯团体名次只落在 channel_ranks,而 results.rank 被有意收窄成
   「个人名次」、对团体主体一直是 NULL;大屏的两个读者只读 results.rank、没有兜底
   (导出件与积分明细下钻早就有兜底,所以当时是「导出有、屏幕没有」)。
   改成读侧回落:results.rank 优先、为空时用通道名次。算分与写回一行未动。
   ⚠️ 优先级的理由:只更新名次的发布只更新 results.rank、不重建通道名次,
   若通道名次优先,个人项目会在那种发布之后显示旧名次。
   ⚠️ 已发布的数据不需要重发或重算,升级后大屏下一次刷新即显示。
   ⚠️ 仍未覆盖:只在「团体名次」卡片里按单位录名次、成绩行上没填的那类(无成绩行可挂)。

② 分项成绩页第 3 栏看不到第 1 名
   根因:快照 latestResults 的窗口原来按「行」截断(LIMIT 20),而屏上是一组一栏,
   切口只能落在最旧那一栏(通常是第 3 栏)内部,把该组建号最早的行挤掉
   (手录项目从第 1 名往下录 → 第 1 名建号最早)。
   改成「最近 8 组 × 每组 10 行」(先按每组最新发布时间取 8 组,再每组按名次取前 10 行);
   组的 publishedAt 改为组内最新一条。底栏「最新完成项目」一起吃这个修复。
   ⚠️ 取舍:单组超过 10 行的部分不再下发(前端每栏只画 6 行、实时赛况上限 10 行);
   将来把每栏行数上限调到 10 以上时,必须同步调这里的 R。

③ 录分列表「运动员 / 队」列可限宽 + 逐行展开收起
   多人团体名单以前把整列撑宽。收起限宽 14em(省略号)、超过 24 个显示宽度
   (中文算 2)出现「展开 / 收起」;个人项目不出现按钮;展开状态只在本机内存。
   取舍:展开那一行会把列略微撑宽(成绩列右移约 80px),收起即恢复。

④ 录入页「以决赛成绩发布」从常驻勾选框改成发布弹窗里二选一
   默认「只更新名次(不计分、不发奖牌)」;每次打开都复位、不吃上一次;
   Alt+Enter 走同一个入口;取消 = 什么都不发生。
   审核页保持原样的常驻勾选框(两个页面口径一样、形态不同)。

⑤ 大屏控制页「在线大屏」列表:可读设备名 + 手动重命名 + 修重复条目
   - 自动设备标签(系统 · 浏览器 · 分辨率 · 手机/平板/桌面),由大屏端在握手时上报,
     服务端截断到 80/300 字符并清掉控制字符;老客户端不报时列表与升级前逐字一致。
     ⚠️ 浏览器没有读主机名/设备名的 API,自动标签只是辅助区分。
   - 手动重命名(录入员及以上,与切页同档):存在系统库 settings 的
     display.instance_aliases(机器级、重启与切赛事都不丢),清除即回到自动标签;
     显示优先级 alias → device → id(id 降级成小字)。上限 200 条,满了拒绝新增并提示,
     不静默顶掉别的设备。设备离线过久条目被清理后别名仍留着。
   - 修:以前给局域网设备切页后列表底部会多出一条该设备的离线条目
     (翻页会补发握手,而浏览器没有稳定 id、服务端每次新开一条实例)。
     现在浏览器本机记住实例 id(localStorage),服务端也复用同一条连接已有的 id。

⑥ 局域网地址条目都能出二维码
   以前只有第一条常驻出码;现在其余每条多一个「二维码」按钮:点一下临时显示,
   再点一次 / Esc / 点空白处收起,同一时刻只留一个,且只为当前打开的那条懒生成 SVG
   (不在渲染时为每条地址都算一遍)。第一条的常驻码与「复制链接」未变。

⑦ 录分列表加「搜姓名 / 号码布」搜索框(人多项目找人不费劲)
   复用既有的「姓名 / 工号 / 号码布」匹配口径(未新增第三份),支持模糊、大小写不敏感、
   全角空格、号码布数字口径(007 搜 7、7 搜 007);命中高亮;提示「找到 N 条 / 共 M 条」;
   × / Esc / 清除三种清空方式;搜不到给明确空状态。
   ⚠️ 搜索只影响表格显示:发布范围、进度条(仍是该批次总人数)、勾选、清空范围、
      预览算分一律不受影响,界面上也写明了这一点。
   ⚠️ 搜索状态下粘贴跟随「屏幕上看到的顺序」(隐藏的行不会被写到)——避免第二条成绩
      静默写到看不见的人身上。

测试:四套自检 ×2 全绿(finalcheck_0515*.log:前端 1096 例 / Console 96 例 / 服务端 14 条 ok,
EXIT=0)。①②都做了红/绿对照(改回旧实现,新用例当场变红;②的失败信息即现场形状
「第 3 栏应从第 1 名开始,实际名次 [5 6 7 8]」)。⑥⑦用真浏览器(playwright + chromium)
实机跑过(24 项 / 29 项全过,含「进度条数字不变」「搜索框里按 Alt+Enter 什么也不发生」)。

无数据库迁移;已有成绩、名次、积分与榜单不变。
文档:《升级说明》新增 dev_0.5.15;《使用手册》发布/录入/大屏控制三块与批量订正过期说法;
《常见问题》新增 Q105–Q107;《部署指南》《上线检查清单》同步;
devdocs/05(hello 新字段 + 别名接口)、13(第五十五轮)、16/06/07/18 与两处前端注释的旧窗口说法一并订正。
This commit is contained in:
AskaEth committed 2026-09-17 18:19:38 +08:00
1 parent ee19b92182
commit 56ed90ff8b
53 files changed
+5675 -221

No files matched your search

+9
View File
@@ -328,6 +328,15 @@ export const adminApi = {
() => [],
),
/**
* 给某台大屏设 / 清别名(`PUT /api/display/instances/{id}/alias`)。
*
* 权限与大屏切页同档(录入员及以上),所以用 callWrite:写操作一律不降级——示例数据模式下
* 返回「改好了」会让操作员以为名字存住了,而它只活在内存里。空串 = 清除别名、回到自动标签。
*/
displayAlias: (id: string, alias: string): Promise<DisplayInstance> =>
callWrite(() => client.display.setAlias(id, alias)),
/**
* 「实时赛况」当前项目(服务端按设置与时序判定)。
*
@@ -119,6 +119,12 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
<ul v-if="details.length > 0" class="confirm-details">
<li v-for="(line, index) in details" :key="index">{{ line }}</li>
</ul>
<!--
调用方自定义的那一问(录入页的「本次怎么发」二选一挂在这里)。
做成插槽而不是再弹一个窗:口径属于这次确认的一部分,分开问会多一次点击、
也多一个「关掉第一个弹窗之后什么都没发生」的中间态。
-->
<slot></slot>
<!--
原因输入:pending 时用 readonly 而不是 disabled——请求已经在服务端跑了,
这时候改文字不会被提交(弹窗本身也锁死了,关不掉),但不该把已填的内容抹成灰。
@@ -87,21 +87,62 @@ export const COLUMN_ALIASES: Record<ImportColumnName, readonly string[]> = {
squad: ['队伍', '队名', '队伍名'],
}
/**
* 折叠后的文本 + 「折叠文本第 i 个 UTF-16 单元来自原文第几个码点」的映射。
*
* `origin` 是给高亮用的:命中位置是在折叠后的串上找到的,要还原回原文字符下标才能切片,
* 而折叠会丢掉 U+FEFF、小写偶有变长(如 İ),不能拿折叠串的下标直接当原文下标。
*/
export interface FoldedText {
text: string
origin: number[]
}
/**
* 逐码点折叠:全角空格(U+3000)→ 半角空格、全角 ASCII(!-~)→ 半角、丢掉 U+FEFF、转小写。
*
* 现场的表是中文 Excel 存的、名字多半是从聊天记录里粘过来的:列名常带全角空格、
* 被输入法写成全角字母、第一格粘着 BOM。搜索关键字同理(中文输入法下打出来的空格就是全角),
* 两边同一口径折叠,看起来一样(甚至多一个看不见的全角空格)的两个串才搜得到。
*
* 按码点迭代(不是逐 UTF-16 单元):代理对(emoji、生僻字)不会被拆成两半。
*/
export function foldText(raw: string): FoldedText {
let text = ''
const origin: number[] = []
for (let index = 0; index < raw.length; ) {
const code = raw.codePointAt(index) ?? 0
const size = code > 0xffff ? 2 : 1
const char =
code === 0xfeff
? ''
: code === 0x3000
? ' '
: code >= 0xff01 && code <= 0xff5e
? String.fromCharCode(code - 0xfee0)
: String.fromCodePoint(code)
const folded = char.toLowerCase()
for (let unit = 0; unit < folded.length; unit += 1) origin.push(index)
text += folded
index += size
}
return { text, origin }
}
/** 搜索 / 表头归一用的折叠文本:再去掉首尾空白(两边同口径,所以「张三 」与「张三」等价)。 */
export function foldSearchText(raw: string): string {
return foldText(raw).text.trim()
}
/**
* 表头格归一:去首尾空白、全角 ASCII 与全角空格转半角、丢掉 U+FEFF、转小写。
* 与服务端 normalizeImportHeader 同一口径——现场的表是中文 Excel 存的,
* 列名常带全角空格 / 被输入法写成全角字母 / 第一格粘着 BOM。
*
* 与搜索框共用同一份折叠实现(foldSearchText):口径分两份写,迟早有一边忘了改。
*/
export function normalizeHeaderCell(raw: string): string {
let folded = ''
for (const char of raw) {
const code = char.codePointAt(0) ?? 0
if (code === 0xfeff) continue
else if (code === 0x3000) folded += ' '
else if (code >= 0xff01 && code <= 0xff5e) folded += String.fromCharCode(code - 0xfee0)
else folded += char
}
return folded.trim().toLowerCase()
return foldSearchText(raw)
}
/** 认一格表头,返回规范列名;认不出的(多余列、备注列)返回 undefined 由调用方忽略。 */
@@ -218,7 +259,46 @@ export function selectionAllowsSubmit(mode: SelectionMode, chosenCount: number):
}
/**
* 选人搜索框的匹配口径:**姓名 / 工号 / 号码布**任一命中即算(大小写不敏感、首尾空格忽略)。
* 一维文本是否命中关键字:两边都按同一口径折叠(大小写不敏感、首尾与全角空格忽略)后包含。
*
* 关键字折叠后为空 = 没在搜,返回 true(不能把名单筛空)。
*
* 搜索框是文本框,这里只做字符串比较;**不要**把它的取值喂给 `type="number"` 的输入框
* (那种框的 v-model 会被 Vue 转成 number,清空才留 '',字符串方法会在渲染期抛错)。
*/
export function textMatchesKeyword(text: string, keyword: string): boolean {
const needle = foldSearchText(keyword)
if (needle === '') return true
return foldSearchText(text).includes(needle)
}
/**
* 号码布是否命中关键字:先按文本包含(与姓名同口径),再按**数字**比一次。
*
* 号码布是手写编号录进系统的,同一批人里「007」与「7」两种写法都会有:只做字符串包含时
* 搜「7」就漏掉带前导零的那批人——而现场看号码布只看它是几号。所以两侧都是纯数字时
* 去掉前导零后相等也算命中(007 与 7、07 与 7 互相都能搜到),不是纯数字(A001、空串)时
* 只走文本口径。
*/
export function bibMatchesKeyword(bib: string | null | undefined, keyword: string): boolean {
const raw = bib ?? ''
if (textMatchesKeyword(raw, keyword)) return true
const left = bibDigits(raw)
const right = bibDigits(foldSearchText(keyword))
return left !== null && right !== null && left === right
}
/** 纯数字号码布去掉前导零后的样子(0 仍是 0);不是纯数字时返回 null。 */
function bibDigits(value: string): string | null {
const digits = value.trim()
if (!/^\d+$/.test(digits)) return null
const stripped = digits.replace(/^0+/, '')
return stripped === '' ? '0' : stripped
}
/**
* 选人搜索框的匹配口径:**姓名 / 工号 / 号码布**任一命中即算
* (大小写不敏感、首尾空格与全角空格忽略,见 foldSearchText)。
*
* 号码布必须在内:它是本系统里人员的唯一标识(先认证件号,没有证件号就看号码布),
* 现场拿着号码布找人是常态,只能按姓名 / 工号搜等于搜不到人。
@@ -226,19 +306,17 @@ export function selectionAllowsSubmit(mode: SelectionMode, chosenCount: number):
* 工号与号码布都可能为空(老数据两个都没填、老服务端也不下发),一律当空串处理。
* 关键字全为空白 = 没在搜,返回 true(不能把名单筛空)。
*
* 搜索框是文本框,这里只做字符串比较;**不要**把它的取值喂给 `type="number"` 的输入框
* (那种框的 v-model 会被 Vue 转成 number,清空才留 '',字符串方法会在渲染期抛错)。
* 录入表格的搜人框(pages/entrySearch.ts)也走这一份:姓名 / 号码布的口径只有这一处实现,
* 两处各写一份迟早会分叉(一边能搜号码布、另一边不能)。
*/
export function athleteMatchesKeyword(
athlete: { name: string; employeeNo?: string | null; bib?: string | null },
keyword: string,
): boolean {
const needle = keyword.trim().toLowerCase()
if (needle === '') return true
return (
athlete.name.toLowerCase().includes(needle) ||
(athlete.employeeNo ?? '').toLowerCase().includes(needle) ||
(athlete.bib ?? '').toLowerCase().includes(needle)
textMatchesKeyword(athlete.name, keyword) ||
textMatchesKeyword(athlete.employeeNo ?? '', keyword) ||
bibMatchesKeyword(athlete.bib, keyword)
)
}
+294 -8
View File
@@ -12,13 +12,24 @@ import { settingsApi } from '../components/system/systemApi'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
import {
ALIAS_CLEAR_HINT,
ALIAS_RENAME_HINT,
aliasPayload,
instanceDisplayName,
instanceIdNote,
instanceTooltip,
} from './displayInstanceLabel'
import {
copyText,
isLanQrKeepTarget,
lanDisplayLinks,
lanEmptyNote,
manualAddressStorage,
parseManualAddress,
readManualAddresses,
showsQrButton,
toggleQr,
writeManualAddresses,
} from './lanDisplay'
@@ -328,6 +339,75 @@ function onVisibilityChange(): void {
if (document.visibilityState === 'visible') void refreshInstances()
}
// ── 给某台设备改名(手动别名,机器级、跟着实例 id 走) ────────────────────────
/** 正在改名的那一行;null = 都不在编辑态。同一时刻只开一个输入框,避免几行一起飘着。 */
const renamingId = ref<string | null>(null)
const renameDraft = ref('')
/** 正在提交的那一行,用来挡住重复点击。 */
const renamePending = ref<string | null>(null)
/**
* 改名按钮的禁用原因(与切页按钮同一口径:未登录 / 服务端未连接)。
*
* 权限与大屏切页同档 = 录入员及以上,所以 gate 只跟 canWrite 走,不再额外要管理员。
*/
const renameGateReason = computed(() =>
canWrite.value ? null : auth.readOnly ? '请先登录后再改名。' : '服务端未连接,先恢复连接再改名。',
)
/** 打开某行的输入框;预填手动别名(自动标签只是兜底,别预填成用户以为「已经设过」的名字)。 */
function startRename(instance: DisplayInstance): void {
renamingId.value = instance.id
renameDraft.value = instance.alias ?? ''
}
function cancelRename(): void {
renamingId.value = null
renameDraft.value = ''
}
/**
* 提交别名。空串 = 清除(服务端口径),所以「保存」与「清除」共用这一条路径。
*
* 成功后**立刻刷新列表**:别名是服务端持久化的,本地那一行自己改改只能骗自己的眼睛;
* 失败一律把服务端原文交给 toast(403 / 404 各说各的话,不能吞成一句「操作失败」)。
*/
async function submitAlias(instance: DisplayInstance, alias: string): Promise<void> {
if (!canWrite.value) {
toast.error('当前不可改名', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
if (data.isMock) {
// 示例数据模式下没有真实服务端,存不了别名;谎报成功会让操作员以为名字记住了。
toast.warning('示例数据模式未真正改名', '后端未就绪,名字不会存下来')
return
}
if (renamePending.value !== null) return
renamePending.value = instance.id
try {
await adminApi.displayAlias(instance.id, alias)
cancelRename()
await refreshInstances()
if (alias === '') toast.success('已清除手动名字', '这台设备回到浏览器报上来的设备标签')
else toast.success(`已改名为「${alias}」`, '只对这台设备生效,刷新与重连都不会丢')
} catch (caught) {
toast.error('改名失败', caught instanceof Error ? caught.message : null)
} finally {
renamePending.value = null
}
}
/** 保存输入框里的内容;首尾空白在这里去掉(粘贴进来的名字几乎都带着)。 */
function saveAlias(instance: DisplayInstance): void {
void submitAlias(instance, aliasPayload(renameDraft.value).alias)
}
/** 清除别名:直接提交空串,不看输入框里现在是什么。 */
function clearAlias(instance: DisplayInstance): void {
void submitAlias(instance, '')
}
// ── 局域网大屏(别的设备用浏览器打开就是一块大屏) ────────────────────────────
const lanInfo = ref<NetworkInfo | null>(null)
@@ -412,19 +492,107 @@ const lanPortMissing = computed(() => lanInfo.value !== null && lanInfo.value.po
const lanManualEnabled = computed(() => lanInfo.value !== null && !lanPortMissing.value)
/**
* 第一个地址的小二维码。
* 给一条地址出二维码 SVG。
*
* 只给第一条地址出码:卡片上摆一排二维码没有意义,现场也只需要一个能随手扫的入口。
* scale 由模块数反推,让不同版本(21–177 模块)渲染出来都落在接近的像素尺寸上。
* 只有「首条常驻码」与「点开的那一条」两条路会调到这里——见下面两个 computed。
*/
function qrSvgFor(url: string): string {
const size = encodeQr(url).size
const scale = Math.max(1, Math.round(QR_PIXELS / (size + QR_MARGIN * 2)))
return qrToSvg(url, { scale, margin: QR_MARGIN, ecc: 'M' })
}
/**
* 第一个地址常驻的小二维码。
*
* 只给第一条地址出常驻码:卡片上摆一排二维码会把首屏拖慢(一个码是几百个 `<rect>`),
* 现场也只需要一个能随手扫的入口;其余各条改成点按钮临时出码(见 openLanQrSvg)。
*/
const lanQrSvg = computed(() => {
const first = lanLinks.value[0]
if (first === undefined) return null
const size = encodeQr(first.url).size
const scale = Math.max(1, Math.round(QR_PIXELS / (size + QR_MARGIN * 2)))
return qrToSvg(first.url, { scale, margin: QR_MARGIN, ecc: 'M' })
return qrSvgFor(first.url)
})
/**
* 当前临时展开的是哪一条地址,`null` 表示都没展开。
*
* 存 IP 而不是下标:手填地址会插到列表**前面**,下标整体后移——拿下标当状态的话,加一条
* 地址会让已经打开的码悄悄换成另一条地址的码(扫出来是别的 IP,现场看不出来)。
* 地址列表的 key 也是 IP,两处口径一致。
*/
const openLanQrIp = ref<string | null>(null)
/**
* 临时展开那一条地址的二维码(懒生成)。
*
* 第一句就返回:没有展开的地址时**一个码都不算**。这是硬要求而不是优化——在渲染时为每条
* 地址都算一次 `qrToSvg`,卡片上就等于摆了一排二维码,首屏会被几百个 `<rect>` 拖慢。
* 所以模板里只读这个 computed,绝不在 `v-for` 里直接调 `qrToSvg`。
*/
const openLanQrSvg = computed(() => {
const ip = openLanQrIp.value
if (ip === null) return null
const link = lanLinks.value.find((item) => item.ip === ip)
if (link === undefined) return null
return qrSvgFor(link.url)
})
/** 点按钮:同一条再点一次=收起,点另一条=切过去(同一时刻只会有一个码)。 */
function toggleLanQr(ip: string): void {
openLanQrIp.value = toggleQr(openLanQrIp.value, ip)
}
/**
* 「点空白处关闭」用 document 上的 click(冒泡阶段,最后才轮到它)。
*
* 为什么是 click 而不是 pointerdown / mousedown:收码会让卡片当场变矮(一个二维码约占 150px),
* 而按下与松开之间隔着这一下重排——码下方那些按钮(「手动添加」的「添加」、下面几行的「复制」)
* 会在按住的一瞬间往上跳,松开时指针已经不在按钮上了,浏览器就不再派发 click:用户看到的是
* 「点了没反应」。真 Chromium 复现过(码开着时点「添加」不生效,移动端更容易碰上)。改成 click
* 后,收码发生在按钮自己的处理函数跑完之后,重排不会再吞掉这一次点击。
*
* 为什么按需挂而不是挂一个常驻的:这页是长开窗口(大屏旁边一直开着),常驻监听每次空白点击
* 都要白跑一遍;按需挂只在「有码」这一小段时间里有开销,且与码同生共死,不会出现「码早关了、
* 监听还挂着」。
*/
function onLanQrDismissClick(event: MouseEvent): void {
// 点在自己的按钮或二维码容器里不算「点空白处」:关闭监听在冒泡末端,不排除按钮的话,按钮
// 刚把码打开、紧接着就轮到它把码关掉(表现为按钮点了没反应);容器内的点击(长按识别、
// 选中地址)也不该收码。判断本身在 lanDisplay 里(isLanQrKeepTarget),那里能跑测试。
const target = event.target
if (target instanceof Element && isLanQrKeepTarget(target)) return
openLanQrIp.value = null
}
/**
* Esc 关闭同样挂在 document 上。
*
* 不写在按钮上的 `@keydown.esc`:焦点并不保证在按钮上(触摸屏点完按钮后焦点可能落在别处,
* Safari 里按钮根本不接收键盘焦点),那时按 Esc 会没反应。挂在 document 上就没有这个前提。
*/
function onLanQrDismissKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') openLanQrIp.value = null
}
function attachLanQrDismiss(): void {
document.addEventListener('click', onLanQrDismissClick)
document.addEventListener('keydown', onLanQrDismissKeydown)
}
function detachLanQrDismiss(): void {
document.removeEventListener('click', onLanQrDismissClick)
document.removeEventListener('keydown', onLanQrDismissKeydown)
}
// 有码打开就挂上两个监听,关掉(点空白处 / Esc / 再点按钮 / 那条地址被删)就摘掉。
// 监听的是「码真的在屏幕上」,所以上面任何一条关闭路径都自动覆盖,不必每处都记得摘。
watch(
() => openLanQrSvg.value !== null,
(open) => (open ? attachLanQrDismiss() : detachLanQrDismiss()),
)
async function refreshLan(): Promise<void> {
// 身份还没就绪时不要「把访客理由写进 lanProblem 就完事」:auth 是异步加载的,
// 挂载那一刻 readOnly 必然为 true,写死之后即使随后拿到管理员身份也没有东西再触发本函数
@@ -486,6 +654,9 @@ onBeforeUnmount(() => {
clearTimeout(copiedTimer)
copiedTimer = null
}
// 临时二维码的两个监听也在这里兜底摘掉:页面被切走时如果还开着码,watch 不会替我们摘
// (组件卸载时状态没人再改),漏摘会随每次进页面越积越多。
detachLanQrDismiss()
document.removeEventListener('visibilitychange', onVisibilityChange)
})
@@ -998,6 +1169,22 @@ async function send(
<MxIcon :name="copiedUrl === item.url ? 'check' : 'copy'" :size="14" />
{{ copiedUrl === item.url ? '已复制' : '复制' }}
</button>
<!--
非首条的地址没有常驻二维码(那会把首屏拖成一排码),改为点这里临时出一个。
data-lan-qr-keep:「点空白处关闭」的 document 监听靠它认出「这是自己的按钮」
(那个监听在冒泡末端,不排除的话它会先关掉、按钮的处理函数再开一次)。
-->
<button
v-if="showsQrButton(index)"
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
data-lan-qr-keep
:aria-expanded="openLanQrIp === item.ip"
title="点击显示二维码(点空白处关闭)"
@click="toggleLanQr(item.ip)"
>
二维码
</button>
</div>
<div v-if="index === 0 && lanQrSvg !== null" class="lan-qr">
<!--
@@ -1007,6 +1194,15 @@ async function send(
<div class="lan-qr-code" v-html="lanQrSvg"></div>
<div class="lan-qr-note">手机扫码直接打开</div>
</div>
<!--
临时二维码:只渲染「当前打开的那一条」(openLanQrSvg 只对那一条算码),
点空白处 / 按 Esc / 再点一次按钮都能关掉,点开另一条则自动把这条收掉。
这里同样带 data-lan-qr-keep:容器内的点击(长按识别、选中地址)不算点空白处。
-->
<div v-if="openLanQrIp === item.ip && openLanQrSvg !== null" class="lan-qr" data-lan-qr-keep>
<div class="lan-qr-code" v-html="openLanQrSvg"></div>
<div class="lan-qr-note">手机扫码直接打开(再点一次「二维码」或点空白处收起)</div>
</div>
<!--
来源不常规的地址(up 位读不到 / 按默认路由推断)照常给链接与二维码——它们往往
真能用——只是说清这条是怎么来的:现场遇到「扫了打不开」时才知道换下一条,
@@ -1072,9 +1268,18 @@ async function send(
</p>
<div v-for="instance in instances" :key="instance.id" class="display-row">
<div class="display-meta">
<!--
悬浮提示挂在信息块上(不是整行):排障要看的那几行都在这里,而右侧的下拉与按钮
各自有 title,别被这一条顶掉。
-->
<div class="display-meta" :title="instanceTooltip(instance)">
<div class="display-name">
<span class="instance-id">{{ instance.id }}</span>
<!-- 名字优先级:手动别名 → 自动设备标签 → 实例 id(纯函数,见 displayInstanceLabel.ts) -->
<span class="instance-name">{{ instanceDisplayName(instance) }}</span>
<!-- 名字不是 id 时,id 降级成一行小字:切页指令点名用的就是它,排障时还得拿得到。 -->
<span v-if="instanceIdNote(instance) !== null" class="instance-id-note">
{{ instanceIdNote(instance) }}
</span>
<span class="mx-badge" :class="instance.online ? 'mx-badge-success' : 'mx-badge-error'">
{{ instance.online ? '在线' : '离线' }}
</span>
@@ -1088,6 +1293,50 @@ async function send(
</div>
<div class="display-sub">最后心跳:{{ formatDateTime(instance.lastHeartbeat) }}</div>
<div v-if="!instance.online" class="display-sub">已离线:指令发不到它,等大屏重新连上再切页。</div>
<!-- 行内改名的输入框:一次只开一个,保存 / 清除都打同一个接口。 -->
<div v-if="renamingId === instance.id" class="rename-row">
<div class="mx-input-wrap rename-input-wrap">
<input
v-model="renameDraft"
class="mx-input rename-input"
type="text"
placeholder="二号楼平板"
:aria-label="`给 ${instance.id} 改名`"
@keyup.enter="saveAlias(instance)"
@keyup.esc="cancelRename"
/>
</div>
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="renamePending === instance.id"
:title="ALIAS_RENAME_HINT"
@click="saveAlias(instance)"
>
{{ renamePending === instance.id ? '保存中…' : '保存' }}
</button>
<button
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
:disabled="renamePending === instance.id"
:title="ALIAS_CLEAR_HINT"
@click="clearAlias(instance)"
>
清除
</button>
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
:disabled="renamePending === instance.id"
@click="cancelRename"
>
取消
</button>
</div>
<p v-if="renamingId === instance.id" class="display-sub rename-note">
清除后回到浏览器报上来的设备标签;名字跟着这台设备,刷新与重连都不会丢。
</p>
</div>
<div class="display-actions">
<select
@@ -1106,6 +1355,16 @@ async function send(
>
{{ targetPending === `instance:${instance.id}` ? '下发中…' : '切到此页' }}
</button>
<!-- 改名的开关:只读访客 / 未连接服务端时置灰,并用 title 说清为什么。 -->
<button
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
:disabled="!canWrite || renamePending !== null"
:title="renameGateReason ?? ALIAS_RENAME_HINT"
@click="startRename(instance)"
>
改名
</button>
</div>
</div>
@@ -1113,6 +1372,9 @@ async function send(
<p>当前状态:{{ locked ? '已锁定' : '自动轮播' }} · 全部大屏页面 {{ pageLabel(currentPage) }}</p>
<p v-if="instancesUpdatedAt !== null" class="muted">最近一次刷新:{{ formatDateTime(instancesUpdatedAt) }}</p>
<p class="muted">「落后」= 该实例确认的版本落后(大屏可能在卡或网络差);离线实例会在服务端保留一段时间,便于排查。</p>
<p class="muted">
名字优先显示手动改的(点「改名」),其次是浏览器报上来的设备信息,都没有才显示实例 id;把鼠标停在条目上能看到原始 UA 与实例 id。
</p>
<p v-if="data.isMock" class="mock">示例数据模式下指令不会真正下发到大屏。</p>
</div>
</div>
@@ -1429,11 +1691,35 @@ async function send(
font-size: 13px;
font-weight: 600;
}
.instance-id {
.instance-name {
overflow-wrap: anywhere;
}
/* 名字已经是别名 / 设备标签时,实例 id 降级成一行小字(切页指令点名的就是它)。 */
.instance-id-note {
font-family: var(--mx-mono);
font-size: 12px;
font-weight: 400;
color: var(--mx-t3);
overflow-wrap: anywhere;
}
/* 行内改名的输入框:占满信息块宽度,按钮跟在后面一排。 */
.rename-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.rename-input-wrap {
flex: 1;
min-width: 120px;
}
.rename-input {
height: 30px;
}
.rename-note {
margin-top: 6px;
}
.instance-page-select,
.screen-page-select {
width: 132px;
+379 -60
View File
@@ -52,7 +52,23 @@ import {
teamRanksPayload,
type TeamRankRow,
} from './teamRanks'
import { athleteCellName, inlineBibLabel } from './entryRowDisplay'
import {
athleteCellClass,
athleteCellName,
athleteToggleLabel,
athleteToggleTitle,
inlineBibLabel,
shouldOfferAthleteToggle,
} from './entryRowDisplay'
import {
SEARCH_EMPTY_HINT,
SEARCH_SCOPE_NOTE,
filterEntryRows,
highlightSegments,
isSearching,
searchCountText,
searchEmptyTitle,
} from './entrySearch'
import {
addShareDraftTeam,
removeShareDraftTeam,
@@ -139,15 +155,17 @@ import {
rememberEntryEventId,
} from './entryInitialEvent'
import {
FINAL_PUBLISH_LABEL,
FINAL_PUBLISH_TITLE,
PREVIEW_RANK_ONLY_HINT,
PUBLISH_BUTTON_TITLE,
PUBLISH_OPTIONS,
boardModeNote,
publishModeBadge,
defaultPublishChoice,
openPublishChoice,
publishChoiceIsFinal,
publishModeConfirm,
publishModeHint,
publishedPointsOnBoard,
rankOnlyOf,
type PublishChoice,
} from './entryPublish'
import { computeEventScore, type ScorableRow } from '../api/scoring'
import EntryPicker from '../components/baseData/EntryPicker.vue'
@@ -268,12 +286,15 @@ const clearDialog = ref(false)
const clearFilter = ref<HeatFilter>('all')
const clearing = ref(false)
/**
* 「以决赛成绩发布」勾选状态,**默认不勾**(不勾 = 本次只更新名次)。
* 发布弹窗里选中的口径,**默认「只更新名次」**(不主动让操作员把预赛当决赛算分)。
*
* 每次发布成功后就复位成不勾:勾选是一次发布的口径,不是这个页面的设置。留着勾选状态,
* 口径不是页面上的设置,而是「这一次发布」的属性:弹窗每次打开都从默认值起步
* (见 requestPublish 里的 openPublishChoice),发布成功后也复位。留着上一次的选择,
* 换到别的小项再点发布就会把一批预赛成绩当成决赛算分——那是这个功能最贵的错法。
*/
const finalPublish = ref(false)
const publishChoice = ref<PublishChoice>(defaultPublishChoice())
/** 请求字段那一层要的布尔:true = 按决赛成绩发布。 */
const finalPublish = computed(() => publishChoiceIsFinal(publishChoice.value))
/** 分批结算状态:延迟结算开启时,当前组可能「已发布但还没结算」。 */
const settlement = ref<SettlementStatus | null>(null)
const settling = ref(false)
@@ -426,6 +447,37 @@ const nextHeat = computed(() => maxHeat(categoryRows.value) + 1)
/** 表格实际显示的行:组别 + 批次两层筛选;统计、防呆与发布口径都跟着它走。 */
const visibleRows = computed(() => filterRowsByHeat(categoryRows.value, selectedHeat.value))
/**
* 搜人关键字(姓名 / 号码布,模糊):起跑人数多的项目(5 公里 / 3 公里)录成绩时
* 找一个名字要找半天,工具栏上这个框就是干这个的。
*
* **纯查看工具**:只过滤表格渲染(displayRows),发布(pendingRows)、进度条分母、
* 勾选与批量操作范围、清空范围、预览算分与名次读的都是 visibleRows(批次口径),
* 一个字都不受影响——现场最怕「筛了一下再点发布,以为只发这几条」。
* 与批次筛选是 AND:两个都生效。**不加防抖**:一屏几十到几百行,纯前端字符串匹配是
* 瞬时的,加了反而显得迟钝。
*/
const searchQuery = ref('')
/** 有没有在搜(全是空白 = 没在搜):过滤、结果提示、空状态都看它。 */
const searching = computed(() => isSearching(searchQuery.value))
/** 表格实际渲染的行:批次筛选之后再按搜索词过滤。只喂给 tbody,不喂给任何口径。 */
const displayRows = computed(() => filterEntryRows(visibleRows.value, searchQuery.value))
/** 结果提示:命中数 / **批次筛选后**的总数(搜索不改变后者)。 */
const searchCountLabel = computed(() =>
searchCountText(displayRows.value.length, visibleRows.value.length),
)
/** 搜不到时的空状态标题(回显关键字,好让人确认自己打的是什么)。 */
const searchEmptyLabel = computed(() => searchEmptyTitle(searchQuery.value))
/** 清除搜索:回到完整名单(输入框的 × 与 Esc 都走这里)。 */
function clearSearch(): void {
searchQuery.value = ''
}
/** 选中的行(只认当前可见的,避免筛选后误改看不见的行)。 */
const selectedRows = computed(() =>
visibleRows.value.filter((row) => selectedEntryIds.value.includes(row.entryId)),
@@ -894,6 +946,13 @@ const MAX_LISTED_ISSUES = 12
const listedIssues = computed(() => [...errors.value, ...warnings.value].slice(0, MAX_LISTED_ISSUES))
const hiddenIssueCount = computed(() => Math.max(0, errors.value.length + warnings.value.length - listedIssues.value.length))
/**
* 发布确认框的标题:口径在弹窗里选,标题只问「这一次怎么发」。
*
* 不按选中的那一项改标题:选项就在标题下面一行,标题再跟着变反而像已经替人做了决定。
*/
const publishDialogTitle = computed(() => `发布${scopeWord.value}:请选择本次的发布口径`)
/** 发布确认框正文:必须让操作员明白本次只会发布已填的部分,不能悄悄少发。 */
const publishMessage = computed(() => {
// 通道配置本身就出不来分时,「影响 0 个单位、最大分差 0 分」是句假话(服务端会 409),
@@ -1073,16 +1132,18 @@ function issueFor(entryId: number): Issue | undefined {
}
/**
* 切换组别:批次筛选与行选择都回到原点。
* 切换组别:批次筛选、行选择与搜索都回到原点。
*
* 批次是「组别之下」的概念,换组别后旧批次号没有意义;选择留着会让人误以为
* 还能对看不见的行做批量设置。
* 还能对看不见的行做批量设置;搜索留着则只会得到一张空表。
*/
function selectCategory(id: number): void {
if (selectedCategoryId.value === id) return
selectedCategoryId.value = id
selectedHeat.value = 'all'
selectedEntryIds.value = []
// 搜索与批次同源:换组别后行完全换了一批,留着关键字只会得到一张空表。
clearSearch()
void loadTeamRanks()
}
@@ -1116,6 +1177,8 @@ async function loadEvent(id: number): Promise<void> {
selectedCategoryId.value = event.categories?.[0]?.id ?? null
selectedHeat.value = 'all'
selectedEntryIds.value = []
// 搜索同样归零:换小项等于换一份名单(与批次筛选、行选择同一个理由)。
clearSearch()
allRows.value = board.entries.map(toRow)
// 记下「上次录入的是哪个小项」:下次进录入页要选回它(按 id,改名不影响)。
// 只在真的加载成功之后写——加载失败还留着旧项目,不该把记忆改掉。
@@ -1516,6 +1579,25 @@ function athleteCellTitle(row: EditableRow): string {
: `${head}|点击修改这条报名的队员(个人分与个人奖牌按队员算)`
}
/**
* 展开了完整名单的行(按 entryId 记)。
*
* 只放在本机内存里:换小项 / 刷新页面就回到收起态。名单长不长是这一屏当时的观感问题,
* 不是要存下来的设置——存了反而会让下次打开时莫名其妙地有一行是撑开的。
*/
const expandedAthleteIds = ref<Set<number>>(new Set())
function athleteCellExpanded(row: EditableRow): boolean {
return expandedAthleteIds.value.has(row.entryId)
}
function toggleAthleteCell(row: EditableRow): void {
// 换一个新 Set 再赋值:ref 只在赋值时通知依赖,原地 add / delete 不会重渲染。
const next = new Set(expandedAthleteIds.value)
if (!next.delete(row.entryId)) next.add(row.entryId)
expandedAthleteIds.value = next
}
async function openAthleteEditor(row: EditableRow): Promise<void> {
athleteEditor.value = {
entryId: row.entryId,
@@ -1768,6 +1850,9 @@ async function saveDraft(silent = false): Promise<boolean> {
function requestPublish(): void {
if (selectedEventId.value === null) return
// 弹窗已经开着时不再走一遍「打开弹窗」:那会把操作员刚在弹窗里选好的口径复位成默认。
// Alt+Enter 是本页级的快捷键(弹窗开着也收得到),这一步防的就是它连按两下。
if (publishDialog.value) return
if (data.isMock) {
// 示例数据模式下 publish 只改内存,提示「发布成功」等于谎报落库,直接停用。
toast.error('示例数据模式不能发布', '后端未就绪,发布不会写入真实赛事')
@@ -1789,6 +1874,9 @@ function requestPublish(): void {
toast.error(`有 ${errors.value.length} 处校验未通过`, errors.value[0]?.message ?? null)
return
}
// 口径在弹窗里选,且**每次都从默认的「只更新名次」起步**:上一次(哪怕是换这个小项之前)
// 选过「按决赛成绩发布」也不沿用,否则会把一批预赛成绩当成决赛算分。
publishChoice.value = openPublishChoice(publishChoice.value)
publishDialog.value = true
}
@@ -1813,9 +1901,9 @@ async function confirmPublish(): Promise<void> {
rankOnly: rankOnlyOf(finalPublish.value),
})
publishDialog.value = false
// 勾选是一次发布的口径,不是页面设置:发完就复位成「只更新名次」,
// 口径是一次发布的属性,不是页面设置:发完就复位成「只更新名次」,
// 免得换个小项再点发布时把预赛成绩当成决赛算分。
finalPublish.value = false
publishChoice.value = defaultPublishChoice()
await reloadAfterSettlementChange()
// 延迟结算下这次发布可能只落成绩:提示要如实说清楚,别让现场以为榜单已经跟着变了。
const deferred = settlement.value?.liveOnPublish === false
@@ -2403,6 +2491,11 @@ function onCellKeydown(event: KeyboardEvent, element: HTMLElement): void {
function onPageKeydown(event: KeyboardEvent): void {
if (!event.ctrlKey && !event.altKey) return
if (event.key !== 'Enter') return
// 来源是搜索框就忽略:在里面按 Alt+Enter 是想接着找人,不是想发布这一组。
// 只挡搜索框这一个来源——成绩格里的 Alt+Enter 仍是发布(那是录入动作的一部分),
// 既有快捷键的语义一个字都不动。
const target = event.target
if (target instanceof HTMLElement && target.dataset.searchInput !== undefined) return
event.preventDefault()
if (saving.value || publishing.value || unpublishing.value) return
if (event.altKey) requestPublish()
@@ -2419,28 +2512,34 @@ function onBeforeUnload(event: BeforeUnloadEvent): void {
/**
* Excel 粘贴:两列及以上时,第一列按单位名匹配行,最后一列作为成绩;
* 单列时从当前行往下顺序填充(07 第 2 节第 4 条)。
*
* 顺序填充走的是**表格上真正显示的那一份**(displayRows = 批次 + 搜索),不是批次口径的
* visibleRows:粘贴本来就是「照着屏幕上从上往下抄」,搜索把行藏起来之后如果还按批次口径往后数,
* 第二条成绩就会落到看不见的人头上——那是静默改错人,比「少填几行」严重得多。
* 发布 / 进度 / 勾选 / 清空那些口径不受影响,它们读的仍是 visibleRows。
*/
function onPaste(event: ClipboardEvent, startEntryId: number): void {
const text = event.clipboardData?.getData('text/plain') ?? ''
if (text.trim() === '') return
// 已发布的行现在是 readonly:浏览器照样会派发 paste 事件(只是不插入文本),
// 放过去的话这一条成绩会被本地悄悄改掉、连确认框都不弹,必须在这里挡住。
const start = visibleRows.value.find((row) => row.entryId === startEntryId)
const rows = displayRows.value
const start = rows.find((row) => row.entryId === startEntryId)
if (start === undefined || rowLocked(start)) return
event.preventDefault()
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== '')
let cursor = visibleRows.value.findIndex((row) => row.entryId === startEntryId)
let cursor = rows.findIndex((row) => row.entryId === startEntryId)
for (const line of lines) {
const columns = line.split('\t').map((column) => column.trim())
if (cursor < 0 || cursor >= visibleRows.value.length) break
let target = visibleRows.value[cursor]
if (cursor < 0 || cursor >= rows.length) break
let target = rows[cursor]
if (target === undefined) break
if (columns.length >= 2) {
const key = columns[0] ?? ''
const matched = visibleRows.value.find(
const matched = rows.find(
(row) => row.teamName.includes(key) || (row.teamShort !== '' && key.includes(row.teamShort)),
)
if (matched !== undefined) target = matched
@@ -2448,7 +2547,7 @@ function onPaste(event: ClipboardEvent, startEntryId: number): void {
} else {
target.resultText = columns[0] ?? ''
}
cursor = visibleRows.value.indexOf(target) + 1
cursor = rows.indexOf(target) + 1
}
toast.info(`已粘贴 ${lines.length} 行`)
}
@@ -2510,27 +2609,9 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<template #actions>
<span v-if="dirty" class="mx-badge mx-badge-warning">有未保存修改</span>
<!--
发布口径的勾选与标记:只影响「发布」这一个动作。
默认不勾 = 本次只更新名次(预赛也要排名次,但不计分、不发奖牌、不动榜单);
勾上 = 与改动前一样,按决赛成绩算分、发牌、上榜。
勾选状态先于发布按钮出现,让操作员在按下去之前就看清楚这一下会发生什么。
工具栏上不再有「以决赛成绩发布」的常驻勾选框:口径改到点「发布」之后弹出的那个
确认框里选(见下方的发布确认框)。工具栏只留按钮本身,悬停告诉操作员下一步会问什么。
-->
<label class="publish-final-toggle" :title="FINAL_PUBLISH_TITLE">
<input
v-model="finalPublish"
type="checkbox"
:disabled="!canWrite || data.isMock || publishing"
:title="FINAL_PUBLISH_TITLE"
/>
<span>{{ FINAL_PUBLISH_LABEL }}</span>
</label>
<span
class="mx-badge publish-mode-badge"
:class="finalPublish ? 'mx-badge-success' : 'mx-badge-warning'"
:title="publishModeHint(finalPublish)"
>
{{ publishModeBadge(finalPublish) }}
</span>
<button
class="mx-btn mx-btn-secondary"
type="button"
@@ -2544,7 +2625,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
class="mx-btn mx-btn-primary"
type="button"
:disabled="!canWrite || publishing || data.isMock || pendingRows.length === 0"
:title="pendingRows.length === 0 ? '没有可发布的成绩:未录行会被自动跳过' : publishModeHint(finalPublish)"
:title="pendingRows.length === 0 ? '没有可发布的成绩:未录行会被自动跳过' : PUBLISH_BUTTON_TITLE"
@click="requestPublish"
>
<MxIcon name="push" :size="15" /> 发布<span v-if="hasPublished && pendingRows.length > 0">本批</span>
@@ -2619,6 +2700,38 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
</option>
</select>
</label>
<!--
搜人(姓名 / 号码布,模糊):5 公里 / 3 公里这种大项目的起跑名单一屏几百行,
找一个名字要找半天。输入即时过滤,不加防抖(纯前端字符串匹配是瞬时的)。
**只是查看工具**:发布范围、进度条、勾选与清空范围、预览算分都不跟着变,
所以这一行小字常驻显示,说清它不影响什么——现场最怕「筛了一下再点发布」。
Esc 与右侧的 × 都能清空;框里按 Alt+Enter 不会误触发发布(见 onPageKeydown)。
-->
<div class="entry-search">
<div class="mx-input-wrap entry-search-box" :title="SEARCH_SCOPE_NOTE">
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
<input
v-model="searchQuery"
data-search-input
class="mx-input"
type="text"
placeholder="搜姓名 / 号码布"
@keydown.esc="clearSearch"
/>
<button
v-if="searching"
class="entry-search-clear"
type="button"
title="清除搜索,回到完整名单(在输入框里按 Esc 也可以)"
@click="clearSearch"
>
<MxIcon name="close" :size="13" />
</button>
</div>
<span class="entry-search-hint">
<template v-if="searching">{{ searchCountLabel }};</template>{{ SEARCH_SCOPE_NOTE }}
</span>
</div>
</div>
<div class="toolbar-right">
<span class="entry-counts">
@@ -2859,13 +2972,13 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<div v-if="hasPublished" class="published-note">
<MxIcon name="lock" :size="15" />
{{ scopeWord }}已有 {{ publishedCount }} 条成绩发布并锁定上屏(共 {{ visibleRows.length }} 条):未发布的行可继续补录,
录完再点一次「发布本批」即可;勾选「以决赛成绩发布」时名次与积分按已发布成绩整体重算。
录完再点一次「发布本批」即可;发布时在弹窗里选「按决赛成绩发布」,名次与积分按已发布成绩整体重算。
要改某一条已发布的成绩,直接点那一格(或右侧「修改本条」)退回草稿,改完再重新发布本组。
</div>
<!--
本组在榜上的口径(预赛还是决赛):只排名 / 已计分一眼看出来。
判据是「已发布成绩有没有拿到分」,也就是榜面上的事实——系统不记「上次勾了什么」,
判据是「已发布成绩有没有拿到分」,也就是榜面上的事实——系统不记上次选了哪种口径,
而榜单本来就只认流水。它同时点破了只更新名次发布后的「名次变了、分还是旧的」。
-->
<div
@@ -2888,7 +3001,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
type="checkbox"
:checked="allVisibleSelected"
:disabled="visibleRows.length === 0"
:title="`全选当前筛选范围内的行(${scopeWord} / 当前组别,共 ${visibleRows.length} 行)`"
:title="`全选当前筛选范围内的行(${scopeWord} / 当前组别,共 ${visibleRows.length} 行${searching ? ';搜索不改变勾选与批量操作的范围,这里仍是全部行' : ''})`"
@change="toggleAllVisible"
/>
</th>
@@ -2914,7 +3027,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
</thead>
<tbody>
<tr
v-for="row in visibleRows"
v-for="row in displayRows"
:key="row.entryId"
:class="{
'is-dirty': rowDirty(row),
@@ -2983,8 +3096,30 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<span v-if="row.squad !== ''" class="joint-member">队伍 {{ row.squad }}</span>
</span>
</td>
<!-- 号码布紧跟姓名重复一次:左边那列还在(有人习惯了),这一个是给眼睛省路的。 -->
<td>{{ athleteCellName(row) }} <span v-if="inlineBibLabel(row.bib)" class="bib-inline mono">{{ inlineBibLabel(row.bib) }}</span>
<!--
号码布紧跟姓名重复一次:左边那列还在(有人习惯了),这一个是给眼睛省路的。
多人团体 / 接力的名单很长,收起时限宽 + 省略号(见 .athlete-cell-text),
别让这一列把成绩列一起挤出屏;展开按行记在本机内存里,换小项 / 刷新即回到收起态。
搜索时把命中的那几个字标出来(只加底色,文字仍是原文,切法见 entrySearch.ts):
一屏几百行里「找到的是不是这个人」要一眼确认,不必再去看号码布列。
没在搜索时走上面那一条分支,渲染与搜索功能加入前逐字相同。
-->
<td>
<span
class="athlete-cell-text"
:class="athleteCellClass(athleteCellExpanded(row))"
><span v-if="!searching" class="athlete-cell-name">{{ athleteCellName(row) }}</span><span v-else class="athlete-cell-name"><template v-for="(segment, index) in highlightSegments(athleteCellName(row), searchQuery)" :key="index"><mark v-if="segment.hit" class="entry-hit">{{ segment.text }}</mark><template v-else>{{ segment.text }}</template></template></span></span> <span v-if="inlineBibLabel(row.bib)" class="bib-inline mono">{{ inlineBibLabel(row.bib) }}</span>
<!-- 只有长到会被截断的名单才给这个按钮:个人项目的姓名永远不该出现「展开」。 -->
<button
v-if="shouldOfferAthleteToggle(athleteCellName(row))"
class="mx-btn mx-btn-ghost mx-btn-sm athlete-toggle"
type="button"
:title="athleteToggleTitle(athleteCellExpanded(row))"
@click.stop="toggleAthleteCell(row)"
>
{{ athleteToggleLabel(athleteCellExpanded(row)) }}
</button>
<!--
队员(devdocs/20):只有纯团体报名才有这个按钮——个人报名的身份就是那一个运动员。
老数据(没有队员关联)的文案一个字符都没变,这里只是多了一个改的入口。
@@ -3132,6 +3267,21 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<span v-if="rowActionsOf(row).length === 0" class="muted">—</span>
</td>
</tr>
<!--
搜不到:不能只留一张空表——现场会以为这批人根本没报名。
说清「是搜索没匹配上」(回显关键字)+ 一个一键清除的按钮。
-->
<tr v-if="!loading && searching && visibleRows.length > 0 && displayRows.length === 0">
<td colspan="12">
<div class="mx-empty">
<div class="mx-empty-title">{{ searchEmptyLabel }}</div>
<div class="mx-empty-desc">{{ SEARCH_EMPTY_HINT }}</div>
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" @click="clearSearch">
<MxIcon name="close" :size="14" /> 清除搜索
</button>
</div>
</td>
</tr>
<tr v-if="!loading && visibleRows.length === 0">
<td colspan="12">
<div class="mx-empty">
@@ -3170,10 +3320,10 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
</div>
<template v-else>
<!--
预览永远按「计分发布」算分:不勾「以决赛成绩发布」时这些分差这次不会发生,
卡片里必须说破,否则操作员会以为按了发布就涨了这些分。
预览永远按「计分发布」算分:发布弹窗里选了「只更新名次」时这些分差这次不会发生。
口径要等点发布才选,所以这句常驻显示,而不是跟着某个勾选状态出现 / 消失。
-->
<p v-if="!finalPublish" class="preview-hint preview-rank-only-hint">
<p class="preview-hint preview-rank-only-hint">
{{ PREVIEW_RANK_ONLY_HINT }}
</p>
<p class="preview-hint">
@@ -3320,17 +3470,47 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
</section>
</div>
<!--
发布确认框,也是本页唯一的发布入口(工具栏按钮 / Alt+Enter 都走 requestPublish):
口径就在这一个弹窗里问完 —— 上面那组二选一挂在 ConfirmDialog 的默认插槽里,
不再另开第二个弹窗,也不在工具栏上留常驻勾选框。
-->
<ConfirmDialog
:open="publishDialog"
tone="warning"
:title="finalPublish ? '确认按决赛成绩发布该组?' : '确认发布该组(只更新名次)?'"
:title="publishDialogTitle"
:message="publishMessage"
:details="warnings.map((issue) => issue.message)"
confirm-text="确认发布"
:pending="publishing"
@confirm="confirmPublish"
@cancel="publishDialog = false"
/>
>
<div class="publish-choice">
<span class="publish-choice-head">本次怎么发?</span>
<label
v-for="option in PUBLISH_OPTIONS"
:key="option.value"
class="publish-choice-option"
:class="{ active: publishChoice === option.value }"
>
<input
v-model="publishChoice"
type="radio"
name="publish-choice"
:value="option.value"
:disabled="publishing"
/>
<span class="publish-choice-text">
<span class="publish-choice-label">
{{ option.title }}
<span v-if="option.badge !== ''" class="mx-badge mx-badge-warning">{{ option.badge }}</span>
</span>
<span class="publish-choice-hint">{{ option.hint }}</span>
</span>
</label>
</div>
</ConfirmDialog>
<!--
补报名:外壳照页面里既有的写法(RecordsPage 的新增纪录弹窗)自己套一层,
@@ -3806,6 +3986,56 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
width: auto;
min-width: 104px;
}
/*
* 搜人框:紧挨批次下拉,观感与它一档(同一套 mx-input-wrap / mx-input),不抢右上角按钮的视线。
* 只是查看工具,所以旁边那行小字常驻显示(见 SEARCH_SCOPE_NOTE)。
*/
.entry-search {
display: inline-flex;
align-items: center;
gap: 8px;
}
.entry-search-box {
position: relative;
width: 220px;
}
/* 给右侧的清除按钮留出位置,否则输入的字会钻到 × 底下。 */
.entry-search-box .mx-input {
padding-right: 22px;
}
.entry-search-clear {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: 0;
border-radius: var(--mx-r-xs);
background: transparent;
color: var(--mx-t3);
cursor: pointer;
}
.entry-search-clear:hover {
color: var(--mx-text);
background: var(--mx-divider);
}
.entry-search-hint {
font-size: 12px;
color: var(--mx-t3);
white-space: nowrap;
}
/* 命中片段:用本页既有的强调色,只让人认出是哪几个字,不与成绩格的错误态抢注意力。 */
.entry-hit {
background: var(--mx-primary-glow);
color: var(--mx-primary);
border-radius: var(--mx-r-xs);
padding: 0 1px;
}
/* 批量设置批次的临时操作条:只有勾了行才出现。 */
.heat-bar {
display: flex;
@@ -3959,24 +4189,60 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
color: var(--mx-green);
font-size: 13px;
}
/* 发布口径勾选:贴着发布按钮,未勾(只更新名次)用警示色,勾上再用主色。 */
.publish-final-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
/*
发布弹窗里的口径二选一:挂在 ConfirmDialog 的默认插槽上(作用域样式跟着插槽内容走)。
与「平均值口径」那一组同一种观感:一个选项一行,标题 + 说明,选中项用主色。
*/
.publish-choice {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
padding: 10px 12px;
border-radius: var(--mx-r-sm);
border: 1px solid var(--mx-primary);
background: var(--mx-primary-glow);
}
.publish-choice-head {
font-size: 13px;
font-weight: 600;
color: var(--mx-primary);
}
.publish-choice-option {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13px;
color: var(--mx-t2);
cursor: pointer;
user-select: none;
}
.publish-final-toggle input {
.publish-choice-option input {
margin-top: 2px;
flex: none;
cursor: pointer;
}
.publish-final-toggle input:disabled {
.publish-choice-option input:disabled {
cursor: not-allowed;
}
.publish-mode-badge {
white-space: nowrap;
.publish-choice-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.publish-choice-label {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--mx-t1);
}
.publish-choice-option.active .publish-choice-label {
color: var(--mx-primary);
font-weight: 600;
}
.publish-choice-hint {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.6;
}
/*
本组在榜上的口径(预赛 / 决赛):只排名用中性色(常态),已计分用绿色(有价值的信息),
@@ -4512,6 +4778,52 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
font-size: 11px;
color: var(--mx-t3);
}
/*
* 「运动员 / 队」格里的姓名文本:收起时限宽 + 省略号。
*
* 限宽取 14em(13px 正文字号下 182px):这一格是 nowrap,不限宽的话多人团体 / 接力的
* 名单会把整列撑开,把右边的成绩格挤出屏幕——八个人(约 110 个显示宽度)实测就有 698px。
* 182px 装得下两个人带号码布的名单(`张伟(A001)、李强(B002)` 26 个显示宽度,实测 170px),
* 加上号码布、小按钮与单元格内边距也就 290px 上下:1440px 的一屏上
* 单位列、姓名列、成绩列(.result-cell 的 min-width: 140px)能同时看得见。
* 14em = 28 个显示宽度(12 个汉字 = 156px),大于 ATHLETE_TOGGLE_THRESHOLD(24 个显示宽度),
* 保证「不出现展开按钮的文本一定没有被截断」。
*
* 限宽在外层、省略号在内层:带 overflow:hidden 的 inline-block 基线会变成下边框,
* 姓名会比旁边的号码布、「队员」按钮翘高半行;把 overflow 挪进内层后外层照常按基线对齐,
* 三种字号仍落在同一条基线上。
*/
.athlete-cell-text {
display: inline-block;
max-width: 14em;
}
.athlete-cell-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/*
* 展开态:放开限宽、允许换行。
* 换行优先落在「、」上(keep-all 不拆汉字词),一个「姓名(号码布)」本身太长时
* 才用 anywhere 从中间断开——宁可断字,也不能溢出这一格去挤成绩列。
*
* 展开时改成 block:这一格是 nowrap,姓名块若和后面的号码布、「队员」按钮同处一行,
* 它把整行占满后号码布与按钮就会被顶出格子、压在成绩列上(实测过);
* 变成 block 让号码布与按钮落到下面一行,格子只长高、不会横向溢出。
*/
.athlete-cell-text.is-expanded {
display: block;
max-width: none;
}
.athlete-cell-text.is-expanded .athlete-cell-name {
white-space: normal;
word-break: keep-all;
/* anywhere 同时能把这一格的最小宽度压小,表格挤的时候先让这一列换行;老引擎不认
anywhere,补一个 break-word 兜底——展开态没有省略号,被 overflow:hidden 裁掉就再也看不到了。 */
overflow-wrap: break-word;
overflow-wrap: anywhere;
}
/* 「队员」入口:紧跟在队名单后面,弱化成一个小按钮,不抢姓名与成绩的位置。 */
.athlete-edit {
margin-left: 6px;
@@ -4519,6 +4831,13 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
font-size: 11px;
line-height: 18px;
}
/* 「展开 / 收起」入口:与「队员」同一套观感(同一组 mx-btn-ghost mx-btn-sm 覆盖),排在它前面。 */
.athlete-toggle {
margin-left: 6px;
padding: 0 6px;
font-size: 11px;
line-height: 18px;
}
/* 队员候选列表:与补报名里的队员勾选同一套观感(那套是 EntryPicker 的 scoped 样式,
作用不到这里,所以在这边按同一份排版自己写一份)。 */
.athlete-choice-list {
@@ -0,0 +1,66 @@
import type { DisplayInstance } from '@/shared/api'
/**
* 「在线大屏」列表里一台设备怎么显示。
*
* 抽成纯函数是因为组件跑不进测试(仓库里没有 jsdom):名称优先级、悬浮提示里到底有哪几行、
* 别名请求体怎么组装,这三件事一旦写错,现场看到的就是「列表里全是 display-a1b2c3d4」
* 或者「清别名把名字清成了空格」,而 vue-tsc 一个字都不会说。
*/
/** 空串按「没有」处理:服务端可空字段与老服务端缺字段都长这样。 */
function nonEmpty(value: string | undefined | null): string | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
return trimmed === '' ? null : trimmed
}
/**
* 列表主行显示的名字,优先级:手动别名 → 自动设备标签 → 实例 id。
*
* 别名是操作员自己起的(「二号楼平板」),永远压过自动标签;自动标签是浏览器报上来的
* 设备信息,比一串 `display-a1b2c3d4` 强;两者都没有(老客户端 / 老服务端)时退回 id,
* 至少排障时还认得出是哪一条。
*/
export function instanceDisplayName(instance: DisplayInstance): string {
return nonEmpty(instance.alias) ?? nonEmpty(instance.device) ?? instance.id
}
/** 主行已经不是 id 时,id 降级成一行小字;名字本身就是 id 时不重复渲染一遍。 */
export function instanceIdNote(instance: DisplayInstance): string | null {
return instanceDisplayName(instance) === instance.id ? null : instance.id
}
/**
* 悬浮提示:排障要用的那几行,缺的整行不出现。
*
* 设备标签在最上面(被截断过也能一眼看出是哪台),紧接着是原始 UA —— 想知道「到底是什么机型」
* 只能看它;来源 IP 与屏号用来对上「哪台机器」;id 是切页指令点名的目标值。
*/
export function instanceTooltip(instance: DisplayInstance): string {
const lines: Array<[string, string | null]> = [
['设备', nonEmpty(instance.device)],
['原始 UA', nonEmpty(instance.deviceRaw)],
['实例 id', instance.id],
['来源', nonEmpty(instance.remoteAddr)],
['屏号', nonEmpty(instance.screen)],
]
return lines
.filter((line): line is [string, string] => line[1] !== null)
.map(([label, value]) => `${label}:${value}`)
.join('\n')
}
/** 界面提示:改名与清除分别做什么(按钮 title 与就地说明共用一句)。 */
export const ALIAS_RENAME_HINT = '给这台设备起个认得出的名字(例如「二号楼平板」),只对这台设备生效'
export const ALIAS_CLEAR_HINT = '清除手动名字,回到浏览器报上来的设备标签'
/**
* 别名请求体。
*
* 服务端口径:空串或只空白 = 清除别名、回到自动标签。这里统一 trim —— 现场从聊天记录里
* 粘贴过来的名字几乎必然带上首尾空格,不 trim 就会存成一个「看着一样、实际多两个空格」的名字。
*/
export function aliasPayload(input: string): { alias: string } {
return { alias: input.trim() }
}
+96 -17
View File
@@ -1,36 +1,109 @@
import { formatPoints } from '@/shared'
/**
* 「以决赛成绩发布」勾选项的口径与文案。
* 发布口径(只更新名次 / 按决赛成绩发布)的口径与文案。
*
* 现场只有最后一轮(决赛)的成绩算分,所以发布入口多了一个勾选:
* 不勾(默认)= 本次只更新名次(预赛也要排名次),不计分、不发奖牌、榜单分数不变;
* 勾上 = 与改动前完全一样:算分、发牌、上榜。
* 现场只有最后一轮(决赛)的成绩算分,所以发布入口多了一问:
* 只更新名次(默认)= 本次只更新名次(预赛也要排名次),不计分、不发奖牌、榜单分数不变;
* 按决赛成绩发布 = 与改动前完全一样:算分、发牌、上榜。
*
* 单独成一个模块(而不是散在页面里),是为了让「勾选状态 → 请求字段 → 各处文案」这条链子
* 这一问**在录入页点「发布」之后弹出的确认框里问**(原来挂在工具栏上的常驻勾选框已撤掉):
* 口径只在「这一次发布」上有意义,而弹窗本身就带着上下文(发的是哪一组、会影响什么)。
* 默认值仍是「只更新名次」,并且**每次打开弹窗都从默认值起步**,绝不沿用上一次的选择——
* 沿用会把一批预赛成绩当成决赛算分,那是这个功能最贵的错法。
*
* 单独成一个模块(而不是散在页面里),是为了让「弹窗选项 → 请求字段 → 各处文案」这条链子
* 能脱离组件单测:文案说反了现场就会按错的口径发布,这是本功能最容易出错的地方。
*/
/** 勾选框文案:一句话说清勾上之后会发生什么。 */
/**
* 审核页(成绩审核 → 发布整组)仍然是常驻勾选框,这两个常量归它用。
* 录入页已改成弹窗里二选一,不再引用它们。
*/
export const FINAL_PUBLISH_LABEL = '以决赛成绩发布(勾选才计分发牌)'
/** 勾选框的悬停说明:两种口径各会发生什么。 */
export const FINAL_PUBLISH_TITLE =
'勾上 = 本次按决赛成绩发布:重算名次并计分、发奖牌、榜单跟着更新;不勾(默认)= 本次只更新名次,不计分、不发奖牌,榜单分数不变。'
/** 请求字段:不勾就是「只更新名次」。 */
/** 请求字段:没选「按决赛成绩发布」就是「只更新名次」。 */
export function rankOnlyOf(finalPublish: boolean): boolean {
return !finalPublish
}
/** 勾选框旁的常驻提示:未勾时必须说清「这一下不计分」。 */
/**
* 发布弹窗里选中的口径。
*
* 用字符串而不是布尔:布尔叫 `finalPublish` 时,「false / true 各代表什么」在调用处看不出来,
* 而这种功能恰恰错不起;选项值直接就是标签页上印着的那两个词。
*/
export type PublishChoice = 'rank-only' | 'final'
/** 弹窗默认选中的口径:只更新名次(不主动让操作员把预赛当决赛算分)。 */
export function defaultPublishChoice(): PublishChoice {
return 'rank-only'
}
/**
* 弹窗每次打开时的初始选择:**永远**是默认的「只更新名次」。
*
* 参数故意收下上一次选过什么(换小项前选过「按决赛成绩发布」也算):签名上的 `_previous`
* 就是「这里不读它」这件事的证据 —— 上一次的选择不能替这一次背书。
*/
export function openPublishChoice(_previous: PublishChoice | null): PublishChoice {
return defaultPublishChoice()
}
/** 选项值 → 请求字段那一层要的布尔(true = 按决赛成绩发布)。 */
export function publishChoiceIsFinal(choice: PublishChoice): boolean {
return choice === 'final'
}
/** 弹窗里一个互斥选项的标题与说明。 */
export interface PublishOption {
value: PublishChoice
/** 单选按钮旁那句。 */
title: string
/** 选项下面的说明,直接复用 publishModeHint 的口径,不另写一套说法。 */
hint: string
/** 默认选中项的标记;不是默认项时为空串。 */
badge: string
}
/** 「只更新名次」这一项的标题。 */
export const RANK_ONLY_OPTION_TITLE = '只更新名次(不计分、不发奖牌)'
/** 「按决赛成绩发布」这一项的标题。 */
export const FINAL_OPTION_TITLE = '按决赛成绩发布(重算名次并计分、发奖牌、榜单跟着更新)'
/** 两种口径各会发生什么:录入页弹窗的选项说明、审核页的常驻提示都用它(不进请求)。 */
export function publishModeHint(finalPublish: boolean): string {
return finalPublish
? '本次按决赛成绩发布:算分、发奖牌、榜单跟着更新。'
: '本次只更新名次:不计分、不发奖牌,榜单分数不变。'
}
/** 勾选框旁的短标记(徽标文案),两种口径一眼可辨。 */
/**
* 弹窗里的两个互斥选项:顺序固定为「默认项在前」。
*
* 说明文案取自 publishModeHint(本模块唯一一处口径说明),避免选项说明与常驻提示各说一套。
*/
export const PUBLISH_OPTIONS: readonly PublishOption[] = [
{
value: 'rank-only',
title: RANK_ONLY_OPTION_TITLE,
hint: publishModeHint(false),
badge: '默认',
},
{
value: 'final',
title: FINAL_OPTION_TITLE,
hint: publishModeHint(true),
badge: '',
},
]
/** 工具栏「发布」按钮的悬停说明:口径改到点下去之后的弹窗里选。 */
export const PUBLISH_BUTTON_TITLE = '点「发布」后弹出窗口选择本次口径:只更新名次(默认)/ 按决赛成绩发布'
/** 口径的短标记(徽标文案),两种口径一眼可辨;审核页的常驻徽标用它。 */
export function publishModeBadge(finalPublish: boolean): string {
return finalPublish ? '计分发布(算分发牌)' : '只更新名次(不计分)'
}
@@ -47,14 +120,20 @@ export function publishModeConfirm(finalPublish: boolean, pointsOnBoard: number)
if (finalPublish) {
return '本次按决赛成绩发布:重算名次并计分发奖牌,榜单立刻跟着变。'
}
const base = '本次只更新名次:不计分、不发奖牌,榜单分数不变(录入页预览里的分差,是勾选「以决赛成绩发布」时的效果)。'
const base =
'本次只更新名次:不计分、不发奖牌,榜单分数不变(录入页预览里的分差,是选「按决赛成绩发布」时的效果)。'
if (pointsOnBoard <= 0) return base
return `${base}注意:本组已有 ${formatPoints(pointsOnBoard)} 分在榜(上次按决赛发布),名次会按这次成绩更新,但那份积分与奖牌会原样保留、不随名次变动。`
}
/** 「实时积分预览」面板在只更新名次时的口径提示(那里的分差这次不会发生)。 */
/**
* 「实时积分预览」面板的口径提示(选「只更新名次」时那里的分差不会发生)。
*
* 预览是在**发布之前**看的,此刻还没有人做过选择,所以这句话不能写「当前是只更新名次」,
* 只能写成「发布时在弹窗里选哪一项会怎样」。
*/
export const PREVIEW_RANK_ONLY_HINT =
'当前是「只更新名次」:这次发布会按上面的名次落库,但不计分——下列分差是勾选「以决赛成绩发布」时的效果。'
'发布时在弹出的窗口里选「只更新名次」(默认)时,这次只按上面的名次落库、不计分——下列分差是选「按决赛成绩发布」时的效果。'
/** 只看已发布成绩在榜上的分(草稿还没结算,不算)。 */
export function publishedPointsOnBoard(rows: readonly { resultState: string | null; points: number | null }[]): number {
@@ -71,13 +150,13 @@ export function publishedPointsOnBoard(rows: readonly { resultState: string | nu
* 本组在榜上的口径(预赛 / 决赛),给录入页那句常驻说明用。
*
* 一条都没发布过时返回 null:没有在榜成绩就没有口径可谈,别多一行噪声。
* 判据是「已发布成绩有没有从流水拿到分」,而不是「上次勾没勾」——系统不记「上次勾了什么」,
* 判据是「已发布成绩有没有从流水拿到分」,而不是「上次选了哪一种」——系统不记上次的选择,
* 而榜单上的分与牌本来就只认流水,这句话描述的正是榜面事实。
*/
export function boardModeNote(publishedCount: number, pointsOnBoard: number): string | null {
if (publishedCount <= 0) return null
if (pointsOnBoard <= 0) {
return '本组在榜上只有名次(上次是按「只更新名次」发布的,不计分):要算分、发奖牌,请勾选「以决赛成绩发布」再发布一次。'
return '本组在榜上只有名次(上次是按「只更新名次」发布的,不计分):要算分、发奖牌,请在发布弹窗里选「按决赛成绩发布」再发布一次。'
}
return `本组在榜上是已计分的成绩(已有 ${formatPoints(pointsOnBoard)} 分来自上一次按决赛发布):不勾「以决赛成绩发布」再发一次只会更新名次,这些积分与奖牌不会被改动。`
return `本组在榜上是已计分的成绩(已有 ${formatPoints(pointsOnBoard)} 分来自上一次按决赛发布):发布时选「只更新名次」再发一次只会更新名次,这些积分与奖牌不会被改动。`
}
+79
View File
@@ -4,6 +4,10 @@
* 抽出来的原因:号码布原本只在左边单独一列,录成绩时眼睛要在姓名与号码之间来回跳,
* 所以姓名紧后面要再显示一次号码布;而「姓名怎么回落」与「号码布为空时不占位」
* 这两件事必须能被 node --test 直接断言——`.vue` 组件跑不进测试。
*
* 另一件事是多人团体 / 接力的名单(`张伟(A001)、李强(A002)、…`)会很长,
* 收起时要限宽截断、长了才给「展开 / 收起」按钮;判据同样必须是纯函数,
* 不然「什么时候该出现按钮」就只能靠肉眼在页面上试。
*/
import { entryRosterText } from '@/shared'
@@ -39,3 +43,78 @@ export function athleteCellName(row: EntryNameLike): string {
export function inlineBibLabel(bib: string | null | undefined): string {
return bib === null || bib === undefined ? '' : bib.trim()
}
/**
* 姓名文本超过这个**显示宽度**才给「展开 / 收起」按钮。
*
* 显示宽度按半角字符算 1、CJK 与全角算 2(见 athleteTextWidth),所以 24 = 12 个汉字。
* 依据:名单里一个人是「姓名(号码布)」约 12 个显示宽度(两字姓名 + 全角括号 + 4 位号码布),
* 两人约 26、四人约 54、八人约 110。取 24 的效果是:个人项目的姓名(最长也就是
* 少数民族姓名那种,约 15)永远不出按钮,三人以上的名单(约 40 起)一定出;
* 两人(26)刚过线也会出——这一点余量是有意的:多一个按了看不出变化的按钮只是小事,
* 「文字被截断了却没有按钮可点」会让人再也看不到完整名单。
*
* 这个值还必须 ≤ CSS 里 .athlete-cell-text 的限宽(14em = 28 个显示宽度),
* 否则不到阈值的文本会被截断又没有按钮可点——测试里钉了这条关系。
*/
export const ATHLETE_TOGGLE_THRESHOLD = 24
/**
* 文本的显示宽度:CJK / 全角字符算 2,其余算 1。
*
* 不用 text.length 是因为「8 个汉字」与「16 个半角字母」在表格里占的宽度相当,
* 按字符个数判会让前者不出现按钮、后者出现,同一批数据看起来自相矛盾。
* 这是粗略口径(比例字体下半角字符宽窄不一),够用来判「要不要给按钮」。
*/
export function athleteTextWidth(text: string): number {
let width = 0
// for...of 按码点迭代:代理对(emoji 等)算一个字符,不会被拆成两个半角。
for (const ch of text) {
width += isWideChar(ch) ? 2 : 1
}
return width
}
/** 是否全角 / CJK:按东亚宽度表取常用区间,够覆盖姓名、号码布、中文标点。 */
function isWideChar(ch: string): boolean {
const code = ch.codePointAt(0) ?? 0
if (code > 0xffff) return true
return (
(code >= 0x1100 && code <= 0x115f) ||
code === 0x2329 ||
code === 0x232a ||
(code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
(code >= 0xac00 && code <= 0xd7a3) ||
(code >= 0xf900 && code <= 0xfaff) ||
(code >= 0xfe30 && code <= 0xfe6f) ||
(code >= 0xff00 && code <= 0xff60) ||
(code >= 0xffe0 && code <= 0xffe6)
)
}
/**
* 这一行的姓名是否长到需要「展开 / 收起」:**严格大于**阈值才算(等于不给按钮)。
*
* 边界取「不含等于」是为了让阈值本身也是一个「不用给按钮」的合法长度——
* 24 个显示宽度(12 个汉字)在 14em 的限宽里放得下,不给按钮也不会被截断。
*/
export function shouldOfferAthleteToggle(text: string): boolean {
return athleteTextWidth(text) > ATHLETE_TOGGLE_THRESHOLD
}
/** 展开 / 收起按钮上的文案。 */
export function athleteToggleLabel(expanded: boolean): string {
return expanded ? '收起' : '展开'
}
/** 展开 / 收起按钮的悬停提示:把点了会发生什么说清楚(这一格还有个「队员」按钮,别点错)。 */
export function athleteToggleTitle(expanded: boolean): string {
return expanded
? '收起完整名单,恢复成一行(收起时限宽,不会挤走成绩列)'
: '展开完整名单(收起时超长部分用省略号省略)'
}
/** 姓名格的 class 串:展开态加 `is-expanded`(放开限宽、允许换行)。 */
export function athleteCellClass(expanded: boolean): string {
return expanded ? 'is-expanded' : ''
}
+116
View File
@@ -0,0 +1,116 @@
/**
* 录入表格的「搜人」纯函数:一行是否命中关键字、结果提示与空状态文案、命中片段切分。
*
* 为什么要有这一层:起跑人数多的项目(5 公里 / 3 公里)一屏几十到几百行,录成绩时
* 找一个名字要找半天。工具栏上那个搜索框就是干这个的。
*
* **匹配口径不在这里**:姓名 / 号码布(含「007 搜 7」的数字口径)全部复用
* components/baseData/entrySelection.ts 里那一份(athleteMatchesKeyword / bibMatchesKeyword),
* 与补报名的选人搜索框同源,不许再抄第三份。
*
* 过滤器只影响表格渲染(**纯查看工具**):发布范围、进度条分母、勾选范围、清空范围与
* 预览算分读的都是批次口径的 visibleRows,与搜索无关(见 EntryPage.vue 里两个列表的分工)。
*/
import { athleteMatchesKeyword, foldSearchText, foldText } from '../components/baseData/entrySelection'
/** 表格里一行的身份字段:个人行看 athleteName,团体 / 接力行看队员名单与队员关联。 */
export interface EntrySearchRow {
athleteName: string
bib?: string | null
/** 团体 / 接力行的**文字名单**(导入表的 member_names);个人行为 null。 */
memberNames?: string[] | null
/** 团体 / 接力行的**队员关联**(有档案的人,带号码布);没有关联时为空数组。 */
athletes?: readonly { name: string; bib?: string | null }[] | null
}
/** 有没有在搜(全是空白 = 没在搜):过滤、结果提示、空状态都看它。 */
export function isSearching(keyword: string): boolean {
return foldSearchText(keyword) !== ''
}
/**
* 一行是否命中:姓名(个人行)/ 队员名单 / 队员关联里的姓名与号码布,任一命中即算。
*
* 团体行的身份就是那份名单——一行挂着一个单位与一支队,只搜 athleteName(团体行为空)
* 等于「接力队里的人一个都搜不到」,而现场找人正是按队员姓名找。
*
* 关键字全为空白 = 没在搜,返回 true(不能把表格筛空)。
*/
export function entryRowMatchesKeyword(row: EntrySearchRow, keyword: string): boolean {
if (!isSearching(keyword)) return true
if (athleteMatchesKeyword({ name: row.athleteName, bib: row.bib }, keyword)) return true
for (const name of row.memberNames ?? []) {
if (athleteMatchesKeyword({ name }, keyword)) return true
}
for (const athlete of row.athletes ?? []) {
if (athleteMatchesKeyword({ name: athlete.name, bib: athlete.bib }, keyword)) return true
}
return false
}
/** 过滤出显示的行(顺序不变,返回新数组,不改动入参)。空关键字 = 原样返回全部。 */
export function filterEntryRows<T extends EntrySearchRow>(rows: readonly T[], keyword: string): T[] {
if (!isSearching(keyword)) return [...rows]
return rows.filter((row) => entryRowMatchesKeyword(row, keyword))
}
/**
* 搜索框旁边那行小字。
*
* 必须常驻可见:现场最怕的就是「筛了一下再点发布,以为只发这几条」。搜索是查看工具,
* 发布 / 进度 / 勾选 / 清空一律仍按批次口径——这句话要写在按钮旁边,而不是藏在提示气泡里。
*/
export const SEARCH_SCOPE_NOTE = '搜索只影响表格显示,不影响发布范围与进度'
/** 结果提示:找到 N 条 / 共 M 条(M 是**批次筛选后**的行数,搜索不改变它)。 */
export function searchCountText(matched: number, total: number): string {
return `找到 ${matched} 条 / 共 ${total} 条`
}
/** 搜不到时的空状态标题;关键字去首尾空白后回显原文,好让人确认自己打的是什么。 */
export function searchEmptyTitle(keyword: string): string {
return `没有匹配「${keyword.trim()}」的运动员`
}
/** 空状态那句提示:把「可以只搜一部分」这件事说清,别让人以为非得打全名。 */
export const SEARCH_EMPTY_HINT = '可以只输入姓名里的一两个字,或号码布的一部分(号码布 007 搜 7 也能找到)'
/** 命中片段的切法:`hit` 为 true 的一段在界面上高亮。 */
export interface SearchSegment {
text: string
hit: boolean
}
/**
* 把文本按关键字切成「命中 / 未命中」交替的片段,界面据此高亮。
*
* 用与匹配**同一个折叠口径**找位置(全角空格、全角 ASCII、大小写都折算),
* 但切出来的文本是**原文**:高亮只加底色,一个字都不能被换成折叠后的样子。
* 折叠后下标与原文下标不是一一对应(U+FEFF 被丢掉、小写偶有变长),所以走 foldText 的
* origin 映射把命中范围还原回原文。
*
* 关键字为空时不切(整段算未命中),与「空查询 = 全部通过」一致。
*/
export function highlightSegments(text: string, keyword: string): SearchSegment[] {
const needle = foldSearchText(keyword)
if (needle === '' || text === '') return [{ text, hit: false }]
const folded = foldText(text)
const segments: SearchSegment[] = []
let cursor = 0
let scan = 0
while (scan + needle.length <= folded.text.length) {
const at = folded.text.indexOf(needle, scan)
if (at < 0) break
const endFolded = at + needle.length
const start = folded.origin[at] ?? text.length
const end = endFolded < folded.origin.length ? (folded.origin[endFolded] ?? text.length) : text.length
if (start > cursor) segments.push({ text: text.slice(cursor, start), hit: false })
segments.push({ text: text.slice(start, end), hit: true })
cursor = end
scan = endFolded
}
if (segments.length === 0) return [{ text, hit: false }]
if (cursor < text.length) segments.push({ text: text.slice(cursor), hit: false })
return segments
}
+45
View File
@@ -193,6 +193,51 @@ export function lanEmptyNote(network: NetworkInfo): string {
return `没找到可用的局域网地址。已检查:${shown.join('、')}${folded}。`
}
/**
* 点「二维码」按钮之后,当前该展开的是哪一条地址(`null` = 一条都没展开)。
*
* 口径有三个:再点同一条=收起、点另一条=切过去、没开时点任何一条=打开它。三件事都在
* 一次点击里决定,写反了(比如同一条点第二次却仍然是它)只有现场点一下才看得出来,
* 所以抽成纯函数,在 node 里把第 0 条与第 N 条都走一遍。同一时刻只留一个码就是靠它:
* 状态里存的是「哪一条」,不是「各条各开不开」。
*
* 泛型只是因为「是不是同一条」这个判断与身份类型无关:页面传 IP 字符串(列表的 key),
* 测试里传下标。
*/
export function toggleQr<T>(current: T | null, clicked: T): T | null {
return current === clicked ? null : clicked
}
/**
* 这条地址要不要给「二维码」按钮。
*
* 首条不给:它下面长期挂着一个码(现场一眼就能扫),再给个按钮会让人以为那个码还得再点一下
* 才出来;其余各条没有常驻码,才需要按钮临时代替。
*/
export function showsQrButton(index: number): boolean {
return index > 0
}
/**
* 「这是二维码自己的地盘,点它不算点空白处」的记号。页面上的按钮与临时二维码容器各带一处。
*
* 按钮上那一处是必须的:关闭监听挂在 document 上(冒泡末端,按钮自己的处理函数之后才轮到它),
* 少了它,按钮刚把码打开、紧接着就被这个监听关掉——按钮看起来完全没反应。
*/
export const LAN_QR_KEEP_SELECTOR = '[data-lan-qr-keep]'
/**
* 点空白处关闭时的排除判断:目标在自己带记号的元素内部就不算「空白处」。
*
* 抽成纯函数是为了能测:`closest` 是 DOM 接口、node 里没有,但按结构注入一个假的就能把
* 「带记号 → 不放行」与「真空白处 → 放行」两条路都跑一遍;写成页面里的内联判断则只剩
* 浏览器里人工点这一条验证途径,而它恰恰是最容易写漏的地方。
*/
export function isLanQrKeepTarget(target: { closest(selector: string): unknown } | null): boolean {
if (target === null || typeof target.closest !== 'function') return false
return target.closest(LAN_QR_KEEP_SELECTOR) !== null
}
/** 手填地址在 localStorage 里的键。带前缀,免得和同域下别的页面撞。 */
export const MANUAL_LAN_STORAGE_KEY = 'scoreboard.display.manualLanAddresses'
@@ -0,0 +1,161 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
/**
* 改名接口的契约:`PUT /api/display/instances/{id}/alias`。
*
* 服务端那一半由另一个代理实现(权限与大屏切页同档 = 录入员及以上,别名跟着实例 id 机器级持久化)。
* 这里钉住前端这一侧:URL 与请求体形状、响应按列表元素同款解析、失败原文必须原样抛出。
*/
interface Captured {
url?: string
method?: string
body?: unknown
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
let reply: () => Response = () => jsonResponse({ ok: true, data: null })
let lastRequest: Captured = {}
// 必须在导入被测模块之前装好:ApiClient 在构造时就把当时的 fetch 绑进实例。
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
lastRequest = { url: String(input), method: init?.method ?? 'GET', body: init?.body }
return reply()
}) as typeof fetch
function respondWith(response: () => Response): void {
lastRequest = {}
reply = response
}
const { ApiError, adminApi } = await import('../api/client')
const LAN_DISPLAY = {
id: 'browser-a1b2c3d4',
online: true,
page: 'standings',
lastHeartbeat: '2026-09-11T10:00:02Z',
connectedAt: '2026-09-11T09:58:00Z',
remoteAddr: '192.168.1.23:52341',
ackVersion: 7,
slow: false,
screen: '',
device: '安卓 13 · Chrome 120 · 1080×2400 @3x · 手机',
deviceRaw: 'Mozilla/5.0 (Linux; Android 13) Chrome/120.0.0.0 Mobile Safari/537.36',
alias: '二号楼平板',
}
test('列表收下 device / deviceRaw / alias 三个新字段', async () => {
respondWith(() => jsonResponse({ ok: true, data: [LAN_DISPLAY] }))
const [instance] = await adminApi.displayInstances()
assert.equal(instance?.device, '安卓 13 · Chrome 120 · 1080×2400 @3x · 手机')
assert.equal(instance?.deviceRaw, LAN_DISPLAY.deviceRaw)
assert.equal(instance?.alias, '二号楼平板')
})
// 老服务端 / 老客户端不带这三个字段(omitempty):缺字段时**整个键不出现**,而不是补空串。
// 这样界面上的「有别名 / 有设备标签」判断只有一个口径(undefined 即没有),老接口也不会被误判成回归。
test('老服务端缺这三个字段时不出现在对象里', async () => {
respondWith(() =>
jsonResponse({
ok: true,
data: [{ ...LAN_DISPLAY, device: undefined, deviceRaw: undefined, alias: undefined }],
}),
)
const [instance] = await adminApi.displayInstances()
assert.ok(instance !== undefined)
assert.equal('device' in instance, false)
assert.equal('deviceRaw' in instance, false)
assert.equal('alias' in instance, false)
// 空串同理(服务端 omitempty 之后也可能给出空串):按「没有」处理。
respondWith(() =>
jsonResponse({ ok: true, data: [{ ...LAN_DISPLAY, device: '', deviceRaw: '', alias: ' ' }] }),
)
const [blank] = await adminApi.displayInstances()
assert.equal(blank?.device, undefined)
assert.equal(blank?.alias, undefined)
})
test('改名用 PUT,请求体是 { alias },实例 id 走路径且做 URL 编码', async () => {
respondWith(() => jsonResponse({ ok: true, data: LAN_DISPLAY }))
const updated = await adminApi.displayAlias('browser-a1b2c3d4', '二号楼平板')
assert.equal(lastRequest.url, '/api/display/instances/browser-a1b2c3d4/alias')
assert.equal(lastRequest.method, 'PUT')
const body = JSON.parse(String(lastRequest.body)) as { alias?: unknown; requestId?: unknown }
assert.equal(body.alias, '二号楼平板')
// 写请求统一带幂等键(服务端外层 cached 认它),这一条不能因为新接口漏掉。
assert.equal(typeof body.requestId, 'string')
assert.equal(updated.alias, '二号楼平板')
assert.equal(updated.id, 'browser-a1b2c3d4')
// 带斜杠 / 空格的 id 也不会把路径拼坏。
await adminApi.displayAlias('a b/c', 'x')
assert.equal(lastRequest.url, '/api/display/instances/a%20b%2Fc/alias')
})
test('清除别名提交空串(服务端口径:空串或只空白 = 回到自动标签)', async () => {
respondWith(() => jsonResponse({ ok: true, data: { ...LAN_DISPLAY, alias: undefined } }))
const cleared = await adminApi.displayAlias('browser-a1b2c3d4', '')
assert.equal((JSON.parse(String(lastRequest.body)) as { alias?: unknown }).alias, '')
assert.equal(cleared.alias, undefined)
})
test('响应格式不符时抛解析失败,不把半截记录当成成功', async () => {
respondWith(() => jsonResponse({ ok: true, data: { id: '' } }))
await assert.rejects(
() => adminApi.displayAlias('browser-a1b2c3d4', 'x'),
(error: Error) => error.message.includes('别名'),
)
})
test('403 / 404 的服务端原文原样抛出,不吞成「操作失败」', async () => {
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'FORBIDDEN', message: '需要录入员及以上权限' } }, 403),
)
await assert.rejects(
() => adminApi.displayAlias('browser-a1b2c3d4', 'x'),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.status, 403)
assert.equal(error.message, '需要录入员及以上权限')
return true
},
)
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'NOT_FOUND', message: '实例已不存在,请刷新在线列表' } }, 404),
)
await assert.rejects(
() => adminApi.displayAlias('gone', 'x'),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.message, '实例已不存在,请刷新在线列表')
return true
},
)
})
// 写操作一律不降级:示例数据模式下返回「改好了」会让操作员以为名字真的存住了。
test('连不上服务端时改名抛错,不降级成假成功', async () => {
respondWith(() => {
throw new TypeError('fetch failed')
})
await assert.rejects(() => adminApi.displayAlias('browser-a1b2c3d4', '二号楼平板'))
})
@@ -0,0 +1,97 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { DisplayInstance } from '@/shared/api'
import {
ALIAS_CLEAR_HINT,
ALIAS_RENAME_HINT,
aliasPayload,
instanceDisplayName,
instanceIdNote,
instanceTooltip,
} from '../pages/displayInstanceLabel'
/**
* 「在线大屏」列表怎么显示一台设备、改名怎么提交。
*
* 现场问题:局域网浏览器的名字是一串 `display-a1b2c3d4`,操作员分不清哪台是哪台。
* 名称优先级一旦写反(自动标签压过手动别名),操作员改完名字刷新一下又变回去,
* 他会以为改名根本没生效——所以这三件事都要钉住。
*/
/** 局域网浏览器:有自动设备标签,来源与屏号可能缺字段。 */
const LAN_DISPLAY: DisplayInstance = {
id: 'browser-a1b2c3d4',
online: true,
page: 'standings',
lastHeartbeat: '2026-09-11T10:00:02Z',
connectedAt: '2026-09-11T09:58:00Z',
remoteAddr: '192.168.1.23:52341',
ackVersion: 7,
slow: false,
screen: '',
device: '安卓 13 · Chrome 120 · 1080×2400 @3x · 手机',
deviceRaw: 'Mozilla/5.0 (Linux; Android 13; 2211133C) AppleWebKit/537.36 Chrome/120.0.0.0 Mobile Safari/537.36',
}
test('名称优先级:手动别名 → 设备标签 → 实例 id', () => {
assert.equal(
instanceDisplayName({ ...LAN_DISPLAY, alias: '二号楼平板' }),
'二号楼平板',
'手动别名必须压过自动标签',
)
assert.equal(instanceDisplayName(LAN_DISPLAY), LAN_DISPLAY.device)
// 老客户端 / 老服务端没有 device:退回 id,至少排障时认得出是哪一条。
assert.equal(instanceDisplayName({ ...LAN_DISPLAY, device: undefined }), 'browser-a1b2c3d4')
assert.equal(instanceDisplayName({ ...LAN_DISPLAY, device: undefined, alias: undefined }), 'browser-a1b2c3d4')
})
test('只空白 / 空串的别名不算名字(清除后回到自动标签)', () => {
assert.equal(instanceDisplayName({ ...LAN_DISPLAY, alias: ' ' }), LAN_DISPLAY.device)
assert.equal(instanceDisplayName({ ...LAN_DISPLAY, alias: '' }), LAN_DISPLAY.device)
// 服务端把只空白当清除,界面这边也必须同口径,否则会出现一行看着有名字、实际是空白的条目。
assert.equal(instanceDisplayName({ ...LAN_DISPLAY, alias: ' ', device: undefined }), 'browser-a1b2c3d4')
})
test('id 降级:名字不是 id 时给一行小字,名字本身就是 id 时不重复渲染', () => {
assert.equal(instanceIdNote(LAN_DISPLAY), 'browser-a1b2c3d4')
assert.equal(instanceIdNote({ ...LAN_DISPLAY, alias: '二号楼平板' }), 'browser-a1b2c3d4')
assert.equal(instanceIdNote({ ...LAN_DISPLAY, alias: undefined, device: undefined }), null)
})
test('悬浮提示:设备 / 原始 UA / id / 来源 / 屏号逐行给出,缺的整行不出现', () => {
const lines = instanceTooltip(LAN_DISPLAY).split('\n')
assert.deepEqual(lines, [
`设备:${LAN_DISPLAY.device}`,
`原始 UA:${LAN_DISPLAY.deviceRaw}`,
'实例 id:browser-a1b2c3d4',
'来源:192.168.1.23:52341',
])
// 屏号是空串(局域网浏览器):不出现这一行,也不出现「屏号:」这种空壳。
assert.doesNotMatch(instanceTooltip(LAN_DISPLAY), /屏号/)
const consoleWindow: DisplayInstance = {
...LAN_DISPLAY,
id: 'LAPTOP-1-2528732444',
screen: '2',
device: undefined,
deviceRaw: undefined,
remoteAddr: '',
}
assert.deepEqual(instanceTooltip(consoleWindow).split('\n'), ['实例 id:LAPTOP-1-2528732444', '屏号:2'])
})
test('别名请求体:首尾空白去掉,空串 / 纯空白按清除提交', () => {
assert.deepEqual(aliasPayload(' 二号楼平板 '), { alias: '二号楼平板' })
assert.deepEqual(aliasPayload('二号楼 平板'), { alias: '二号楼 平板' })
assert.deepEqual(aliasPayload(''), { alias: '' })
assert.deepEqual(aliasPayload(' '), { alias: '' })
// 全角空格也是空白(现场从聊天记录里粘过来的名字常带它)。
assert.deepEqual(aliasPayload(' '), { alias: '' })
})
test('两句话提示与按钮 title 口径一致(都不为空且不是同一句)', () => {
assert.ok(ALIAS_RENAME_HINT.includes('只对这台设备生效'))
assert.ok(ALIAS_CLEAR_HINT.includes('设备标签'))
assert.notEqual(ALIAS_RENAME_HINT, ALIAS_CLEAR_HINT)
})
+139 -2
View File
@@ -1,7 +1,16 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { athleteCellName, inlineBibLabel } from '../pages/entryRowDisplay'
import {
ATHLETE_TOGGLE_THRESHOLD,
athleteCellClass,
athleteCellName,
athleteTextWidth,
athleteToggleLabel,
athleteToggleTitle,
inlineBibLabel,
shouldOfferAthleteToggle,
} from '../pages/entryRowDisplay'
// 姓名后重复一次号码布:录入时不用在「号码」列与姓名列之间来回跳。
test('有号码布:原样展示(去掉首尾空白)', () => {
@@ -29,12 +38,140 @@ test('姓名主文本:个人行用姓名,团体 / 接力行回落队员名
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
test('录入页姓名单元格紧随号码布,且用 v-if 保证空号码不占位', () => {
const cell = ENTRY_SOURCE.match(/<td>\{\{ athleteCellName\(row\) \}\}([\s\S]*?)<\/td>/)?.[1]
const cell = ENTRY_SOURCE.match(/<td>\s*<span[\s\S]*?<\/td>/)?.[0]
assert.ok(cell !== undefined, '找不到姓名单元格')
assert.match(cell, /<span v-if="inlineBibLabel\(row\.bib\)"/, '号码布必须带 v-if,空值不渲染')
assert.match(cell, /class="bib-inline mono"/, '号码布要小号、弱化、等宽')
assert.match(cell, /class="athlete-cell-name"/, '姓名文本在自己的元素里,才能单独限宽截断')
})
test('原来的「号码」列保留,没有被这次改动删掉', () => {
assert.match(ENTRY_SOURCE, /<td class="mono">\{\{ row\.bib \?\? '—' \}\}<\/td>/)
})
// —— 多人团体 / 接力的长名单:收起限宽、手动展开 ——
// 显示宽度口径:CJK / 全角算 2,半角算 1。
test('显示宽度:CJK 与全角括号算 2、半角算 1', () => {
assert.equal(athleteTextWidth(''), 0)
assert.equal(athleteTextWidth('张三'), 4)
assert.equal(athleteTextWidth('A001'), 4)
// 「张伟(A001)」= 2 + 2 + 2(全角左括号)+ 4 + 2(全角右括号)
assert.equal(athleteTextWidth('张伟(A001)'), 12)
assert.equal(athleteTextWidth('张伟(A001)、李强(B002)'), 26)
})
// 个人项目:短姓名永远不该出现「展开」,否则每一行都多一个按了没用的按钮。
test('短文本(个人项目的姓名)不出现展开按钮', () => {
assert.equal(shouldOfferAthleteToggle('张三'), false)
assert.equal(shouldOfferAthleteToggle('—'), false)
// 少数民族姓名是最长的个人姓名形态,仍然不该出现按钮。
assert.equal(shouldOfferAthleteToggle('阿依古丽·买买提'), false)
})
// 多人团体 / 接力的名单:4–8 个人的「姓名(号码布)」串必须出现按钮。
test('多人团体的长串(4–8 人)出现展开按钮', () => {
const four = '张伟(A001)、李强(A002)、王芳(A003)、刘洋(A004)'
const eight = '张伟(A001)、李强(A002)、王芳(A003)、刘洋(A004)、赵敏(A005)、孙磊(A006)、周涛(A007)、吴静(A008)'
assert.equal(shouldOfferAthleteToggle(four), true)
assert.equal(shouldOfferAthleteToggle(eight), true)
// 阈值是 24,四个人(54)与八个人(110)都远超,长到这个程度的名单才会被截断。
assert.ok(athleteTextWidth(four) > ATHLETE_TOGGLE_THRESHOLD)
assert.ok(athleteTextWidth(eight) > ATHLETE_TOGGLE_THRESHOLD)
})
// 12 个汉字 = 24 个显示宽度,正好压在阈值上。
const EDGE_CJK = '甲'.repeat(ATHLETE_TOGGLE_THRESHOLD / 2)
// 边界口径:**严格大于**阈值才出现按钮,正好等于阈值(不含等于)不出现。
test('阈值边界:正好等于阈值不给按钮(不含等于),多 1 个显示宽度才给', () => {
assert.equal(athleteTextWidth(EDGE_CJK), ATHLETE_TOGGLE_THRESHOLD)
assert.equal(shouldOfferAthleteToggle(EDGE_CJK), false, '等于阈值:不含')
assert.equal(shouldOfferAthleteToggle(`${EDGE_CJK}甲`), true, '超过阈值 1(半角口径 +2):含')
})
// 宽度口径证明:不用 text.length,否则 8 个汉字与 16 个半角字符会给出不同结论。
test('CJK 宽度口径:8 个汉字与 16 个半角字符判定一致', () => {
const cjk = '一二三四五六七八'
const ascii = 'abcdefghijklmnop'
assert.equal(cjk.length, 8)
assert.equal(ascii.length, 16)
assert.equal(athleteTextWidth(cjk), athleteTextWidth(ascii))
assert.equal(shouldOfferAthleteToggle(cjk), shouldOfferAthleteToggle(ascii))
// 阈值上下各再钉一对,确保两个口径在边界上也是一致的。
assert.equal(shouldOfferAthleteToggle(EDGE_CJK), false)
assert.equal(shouldOfferAthleteToggle('a'.repeat(ATHLETE_TOGGLE_THRESHOLD)), false)
assert.equal(shouldOfferAthleteToggle(`${EDGE_CJK}甲`), true)
assert.equal(shouldOfferAthleteToggle(`a${'a'.repeat(ATHLETE_TOGGLE_THRESHOLD)}`), true)
})
test('展开 / 收起文案与 class', () => {
assert.equal(athleteToggleLabel(false), '展开')
assert.equal(athleteToggleLabel(true), '收起')
assert.equal(athleteCellClass(false), '')
assert.equal(athleteCellClass(true), 'is-expanded')
assert.notEqual(athleteToggleTitle(false), athleteToggleTitle(true))
})
/*
* 模板契约:`.vue` 跑不进测试,接线忘了没人拦得住,所以直接读源码断言。
* 这里要盯住三件事:姓名文本在 athlete-cell-text 里、展开状态确实驱动了它的 class、
* 展开按钮带 @click.stop(这一格旁边还有单位格的弹窗入口,冒泡误触会一起打开)。
*/
const ATHLETE_CELL =
ENTRY_SOURCE.match(/<td>[\s\S]*?athlete-cell-text[\s\S]*?<\/td>/)?.[0] ?? ''
test('姓名文本放进 .athlete-cell-text,且展开状态绑定到它的 class', () => {
assert.ok(ATHLETE_CELL !== '', '找不到姓名单元格')
assert.match(ATHLETE_CELL, /class="athlete-cell-text"/)
assert.match(ATHLETE_CELL, /:class="athleteCellClass\(athleteCellExpanded\(row\)\)"/)
// 姓名文本仍在自己的 .athlete-cell-name 元素里(限宽截断就挂在它身上)。
// 录入页的搜人框只在**搜索时**把这一句换成命中片段(见 entry-search.test.ts):
// 没在搜索时走的就是这一条分支,渲染与搜索功能加入之前逐字相同。
assert.match(ATHLETE_CELL, /<span v-if="!searching" class="athlete-cell-name">\{\{ athleteCellName\(row\) \}\}<\/span>/)
})
test('展开按钮:只在长名单出现、文案跟着状态、点击不冒泡', () => {
assert.match(ATHLETE_CELL, /v-if="shouldOfferAthleteToggle\(athleteCellName\(row\)\)"/)
assert.match(ATHLETE_CELL, /class="mx-btn mx-btn-ghost mx-btn-sm athlete-toggle"/)
assert.match(ATHLETE_CELL, /\{\{ athleteToggleLabel\(athleteCellExpanded\(row\)\) \}\}/)
assert.match(ATHLETE_CELL, /@click\.stop="toggleAthleteCell\(row\)"/, '必须 @click.stop,别冒泡到单位格的弹窗')
assert.ok(!/@click="toggleAthleteCell/.test(ATHLETE_CELL), '不允许没有 .stop 的写法')
})
test('「队员」按钮与号码布没被这次改动动过', () => {
assert.match(ATHLETE_CELL, /v-if="entryAthletesEditable\(row\)"/)
assert.match(ATHLETE_CELL, /@click="openAthleteEditor\(row\)"/)
assert.match(ATHLETE_CELL, /<span v-if="inlineBibLabel\(row\.bib\)" class="bib-inline mono">/)
})
/*
* 样式契约:限宽与截断全在 CSS 里,同样没法在测试里渲染,只能读源码。
* 另外钉住「限宽 ≥ 阈值」这条关系:短于阈值的文本不该被截断(那时没有按钮可点)。
*/
test('收起态限宽 + 省略号,展开态放开限宽并允许换行', () => {
const box = ENTRY_SOURCE.match(/\.athlete-cell-text \{([\s\S]*?)\}/)?.[1] ?? ''
const name = ENTRY_SOURCE.match(/\.athlete-cell-name \{([\s\S]*?)\}/)?.[1] ?? ''
const expanded = ENTRY_SOURCE.match(/\.athlete-cell-text\.is-expanded \{([\s\S]*?)\}/)?.[1] ?? ''
assert.match(box, /display: inline-block/)
assert.match(box, /max-width: 14em/)
assert.match(name, /overflow: hidden/)
assert.match(name, /text-overflow: ellipsis/)
assert.match(name, /white-space: nowrap/)
assert.match(expanded, /max-width: none/)
assert.match(
ENTRY_SOURCE,
/\.athlete-cell-text\.is-expanded \.athlete-cell-name \{[\s\S]*?white-space: normal/,
'展开态要允许换行,否则展开也看不到全文',
)
})
test('CSS 限宽不小于阈值:不到阈值的文本不会被截断(那时没有按钮可点)', () => {
const em = Number(ENTRY_SOURCE.match(/\.athlete-cell-text \{[\s\S]*?max-width: ([\d.]+)em/)?.[1])
assert.ok(Number.isFinite(em), '找不到 .athlete-cell-text 的 em 限宽')
// 1 个显示宽度 ≈ 0.5em(半角字符),所以限宽换算成显示宽度要 ≥ 阈值。
assert.ok(
em * 2 >= ATHLETE_TOGGLE_THRESHOLD,
`限宽 ${em}em(${em * 2} 个显示宽度)小于阈值 ${ATHLETE_TOGGLE_THRESHOLD}`,
)
})
+353
View File
@@ -0,0 +1,353 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import {
SEARCH_EMPTY_HINT,
SEARCH_SCOPE_NOTE,
entryRowMatchesKeyword,
filterEntryRows,
highlightSegments,
isSearching,
searchCountText,
searchEmptyTitle,
} from '../pages/entrySearch'
import { bibMatchesKeyword } from '../components/baseData/entrySelection'
import { filterRowsByHeat } from '../pages/entryHeat'
/**
* 录入页的搜人框(5 公里 / 3 公里这种大项目一屏几百行,找一个名字要找半天)。
*
* 这里钉两件事:
* 1. **匹配口径**(姓名 / 号码布、大小写与全角空格、007 与 7、团体行的队员名单);
* 2. **它只是查看工具**:过滤出来的行只喂给表格渲染,发布范围、进度条分母、勾选范围、
* 清空范围与预览算分读的仍是批次口径的 visibleRows。第 1 条是纯函数,第 2 条
* `.vue` 跑不进 node --test(无 jsdom),只能读源码断言,仓库里有先例。
*/
/** 个人行:姓名 + 号码布(号码布带前导零,现场就是这么写的)。 */
const zhang = { athleteName: '张伟', bib: '007', memberNames: null, athletes: [] }
/** 号码布为空的行:老数据 / 还没发号码布都很常见。 */
const wang = { athleteName: '王芳', bib: null, memberNames: null, athletes: [] }
/** 团体 / 接力行:姓名列为空,身份在文字名单与队员关联里。 */
const relay = {
athleteName: '',
bib: '021',
memberNames: ['李强', '赵敏'],
athletes: [
{ name: '李强', bib: 'A001' },
{ name: '赵敏', bib: null },
],
}
// ── 姓名 ────────────────────────────────────────────────────────────────────
test('按姓名命中:整名与片段都算(模糊搜索)', () => {
assert.equal(entryRowMatchesKeyword(zhang, '张'), true)
assert.equal(entryRowMatchesKeyword(zhang, '张伟'), true)
assert.equal(entryRowMatchesKeyword(zhang, '伟'), true, '名里的一两个字也要能搜到')
assert.equal(entryRowMatchesKeyword(zhang, '王芳'), false)
})
// ── 号码布 ──────────────────────────────────────────────────────────────────
test('按号码布命中:前导零两种写法互相都能搜到', () => {
// 系统里存的就是现场写的那串(007),直接搜它。
assert.equal(entryRowMatchesKeyword(zhang, '007'), true)
// 手上只看得出是「7 号」时也要能搜到 007。
assert.equal(entryRowMatchesKeyword(zhang, '7'), true)
assert.equal(entryRowMatchesKeyword(zhang, '07'), true)
// 反方向:系统里存的是 7、纸上写的是 007,同样要互相搜得到(数字口径,见 bibMatchesKeyword)。
assert.equal(bibMatchesKeyword('7', '007'), true)
assert.equal(bibMatchesKeyword('7', '7'), true)
assert.equal(bibMatchesKeyword('007', '7'), true)
assert.equal(entryRowMatchesKeyword({ ...zhang, bib: '7' }, '007'), true)
// 数字不同就是不同的人,别把整张表搜成一锅粥。
assert.equal(entryRowMatchesKeyword(zhang, '8'), false)
assert.equal(bibMatchesKeyword('007', '8'), false)
// 只有两侧都是纯数字才走数字口径:带字母的号码布仍按文本包含,不被数字口径牵连。
assert.equal(bibMatchesKeyword('A001', 'A00'), true)
assert.equal(bibMatchesKeyword('A001', '007'), false, 'A001 不是纯数字,别拿 007 去对上它')
})
// ── 大小写 / 空白 / 全角空格 ────────────────────────────────────────────────
test('大小写不敏感、首尾空格忽略', () => {
const latin = { athleteName: 'Zhang Wei', bib: 'a001', memberNames: null, athletes: [] }
assert.equal(entryRowMatchesKeyword(latin, 'zhang'), true)
assert.equal(entryRowMatchesKeyword(latin, ' Zhang Wei '), true)
assert.equal(entryRowMatchesKeyword(latin, 'A001'), true, '号码布大小写不敏感')
assert.equal(entryRowMatchesKeyword(latin, ' a001 '), true)
})
test('全角空格当空白处理:输入法打出来的空格不能让人搜不到', () => {
// 关键字两侧带全角空格(\u3000)= 没在搜任何东西之外的东西,等价于去掉了。
assert.equal(entryRowMatchesKeyword(zhang, '\u3000张伟\u3000'), true)
assert.equal(isSearching('\u3000'), false, '只有全角空格 = 没在搜')
assert.equal(entryRowMatchesKeyword(zhang, '\u3000'), true, '空查询 = 全部通过')
// 名字里带全角空格(从聊天记录 / Excel 粘过来的)也要能按「张 伟」搜到。
const spaced = { athleteName: '张\u3000伟', bib: null, memberNames: null, athletes: [] }
assert.equal(entryRowMatchesKeyword(spaced, '张 伟'), true, '关键字半角空格也要命中名字里的全角空格')
assert.equal(entryRowMatchesKeyword(spaced, '张\u3000伟'), true)
// 全角字母同样折叠:号码布被输入法打成全角也能搜到。
const wide = { athleteName: '李强', bib: 'A001', memberNames: null, athletes: [] }
assert.equal(entryRowMatchesKeyword(wide, 'a001'), true)
})
// ── 空查询 / 空号码布 ───────────────────────────────────────────────────────
test('空查询 = 全部通过(不能把表格筛空)', () => {
const rows = [zhang, wang, relay]
assert.equal(isSearching(''), false)
assert.equal(isSearching(' '), false)
for (const row of rows) assert.equal(entryRowMatchesKeyword(row, ''), true)
const filtered = filterEntryRows(rows, ' ')
assert.deepEqual(filtered, rows)
// 返回的是新数组:调用方(computed)不该拿到同一个引用被下游误改。
assert.notEqual(filtered, rows)
})
test('号码布为空的行不因此被误杀:按姓名照样搜得到,也不会被号码布关键字连坐', () => {
// 号码布为空只影响「按号码布搜」这一维:按姓名搜仍然命中。
assert.equal(entryRowMatchesKeyword(wang, '王芳'), true)
assert.deepEqual(filterEntryRows([zhang, wang], '王'), [wang])
// 反过来,搜号码布时号码布为空的行不该跟着冒出来(空串不能匹配任何关键字)。
assert.deepEqual(filterEntryRows([zhang, wang], '7'), [zhang])
assert.equal(entryRowMatchesKeyword(wang, '007'), false)
})
// ── 团体行 ──────────────────────────────────────────────────────────────────
test('团体行按队员姓名或队员号码布能搜到(身份在那份名单里)', () => {
assert.equal(entryRowMatchesKeyword(relay, '李强'), true, '文字名单里的队员')
assert.equal(entryRowMatchesKeyword(relay, '赵敏'), true)
assert.equal(entryRowMatchesKeyword(relay, 'A001'), true, '队员关联里的号码布')
assert.equal(entryRowMatchesKeyword(relay, '021'), true, '行自己的号码布也要能搜到')
// 姓名列为空(团体行就是这样)不能变成「谁都能匹配」。
assert.equal(entryRowMatchesKeyword(relay, '陈'), false)
assert.deepEqual(filterEntryRows([zhang, relay], '赵'), [relay])
})
// ── 与批次筛选是 AND ────────────────────────────────────────────────────────
test('搜索与批次筛选是 AND:两个都生效', () => {
const rows = [
{ entryId: 1, heat: 1, ...zhang },
{ entryId: 2, heat: 2, ...zhang },
{ entryId: 3, heat: 1, ...wang },
]
const ids = (list: { entryId: number }[]): number[] => list.map((row) => row.entryId)
// 同一批 + 同一关键字 → 交集。
assert.deepEqual(ids(filterEntryRows(filterRowsByHeat(rows, 1), '张伟')), [1])
assert.deepEqual(ids(filterEntryRows(filterRowsByHeat(rows, 2), '张伟')), [2])
assert.deepEqual(ids(filterEntryRows(filterRowsByHeat(rows, 'all'), '张伟')), [1, 2])
// 只筛选批次(没搜)→ 还是整批;只搜(批次 = 全部)→ 所有命中的人。
assert.deepEqual(ids(filterEntryRows(filterRowsByHeat(rows, 1), '')), [1, 3])
assert.deepEqual(ids(filterEntryRows(filterRowsByHeat(rows, 'all'), '王')), [3])
// 批次里没有这个人 → 空表(不是「忽略批次照搜」)。
assert.deepEqual(ids(filterEntryRows(filterRowsByHeat(rows, 'none'), '张伟')), [])
})
// ── 结果提示与空状态文案 ────────────────────────────────────────────────────
test('结果提示:找到 N 条 / 共 M 条(M 是批次筛选后的行数)', () => {
assert.equal(searchCountText(3, 120), '找到 3 条 / 共 120 条')
assert.equal(searchCountText(0, 120), '找到 0 条 / 共 120 条')
assert.equal(searchCountText(120, 120), '找到 120 条 / 共 120 条')
})
test('空状态:回显关键字,并说清可以只搜一部分', () => {
assert.equal(searchEmptyTitle('某某'), '没有匹配「某某」的运动员')
// 首尾空白去掉再回显(输入法容易多打一个空格),里面的空格原样保留。
assert.equal(searchEmptyTitle(' 某某 '), '没有匹配「某某」的运动员')
assert.equal(searchEmptyTitle(' 张 伟 '), '没有匹配「张 伟」的运动员')
assert.match(SEARCH_EMPTY_HINT, /号码布/)
assert.match(SEARCH_EMPTY_HINT, /007/)
})
test('搜索不影响发布与进度这句话本身要写全', () => {
assert.match(SEARCH_SCOPE_NOTE, /只影响表格显示/)
assert.match(SEARCH_SCOPE_NOTE, /不影响发布范围/)
assert.match(SEARCH_SCOPE_NOTE, /进度/)
})
// ── 命中高亮(纯函数) ──────────────────────────────────────────────────────
test('高亮切分:命中的一段 hit = true,其余 false,拼回去等于原文', () => {
assert.deepEqual(highlightSegments('张伟', '伟'), [
{ text: '张', hit: false },
{ text: '伟', hit: true },
])
assert.deepEqual(highlightSegments('张伟', '张伟'), [{ text: '张伟', hit: true }])
assert.deepEqual(highlightSegments('张伟张伟', '张伟'), [
{ text: '张伟', hit: true },
{ text: '张伟', hit: true },
])
})
test('高亮用的是原文:大小写与全角只影响「找得到」,不改写表格里的字', () => {
assert.deepEqual(highlightSegments('Zhang Wei', 'zhang'), [
{ text: 'Zhang', hit: true },
{ text: ' Wei', hit: false },
])
// 全角括号 / 全角字母在原文里保持全角,命中范围按折叠后的位置还原。
assert.deepEqual(highlightSegments('张三(007)', '007'), [
{ text: '张三(', hit: false },
{ text: '007', hit: true },
{ text: ')', hit: false },
])
assert.deepEqual(highlightSegments('A001', 'a001'), [{ text: 'A001', hit: true }])
})
test('高亮:空关键字与空文本都不切,拼回去永远等于原文', () => {
assert.deepEqual(highlightSegments('张伟', ''), [{ text: '张伟', hit: false }])
assert.deepEqual(highlightSegments('张伟', ' '), [{ text: '张伟', hit: false }])
assert.deepEqual(highlightSegments('', '张'), [{ text: '', hit: false }])
assert.deepEqual(highlightSegments('张伟', '李'), [{ text: '张伟', hit: false }])
const cases: Array<[string, string]> = [
['张伟(A001)、李强(B002)', '李强'],
[' 张 伟 ', '张 伟'],
['张三(007)', '7'],
['emoji🏃的姓名', 'emoji'],
['emoji🏃的姓名', '🏃'],
]
for (const [text, keyword] of cases) {
const segments = highlightSegments(text, keyword)
assert.equal(segments.map((segment) => segment.text).join(''), text, `${text} / ${keyword}`)
// 至少有一段命中,否则这几条用例证明不了什么。
assert.ok(segments.some((segment) => segment.hit), `${text} / ${keyword} 应该有命中片段`)
}
})
// ── 源码级接线断言(.vue 跑不进测试) ────────────────────────────────────────
const ENTRY = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
const SEARCH_MODULE = readFileSync(new URL('../pages/entrySearch.ts', import.meta.url), 'utf8')
/**
* 匹配口径只许有一份实现:录入页这一份复用 entrySelection.ts 里的共用函数
* (补报名的选人搜索框用的是同一份,见 athlete-search-bib.test.ts)。
*/
test('不许有第三份匹配逻辑:录入页的搜索模块复用共用口径', () => {
assert.match(
SEARCH_MODULE,
/import \{[\s\S]*?athleteMatchesKeyword[\s\S]*?\} from '\.\.\/components\/baseData\/entrySelection'/,
)
assert.match(SEARCH_MODULE, /athleteMatchesKeyword\(/)
assert.match(SEARCH_MODULE, /bibMatchesKeyword|athleteMatchesKeyword/)
// 自己再写一遍 toLowerCase().includes(...) 就是第三份。
assert.doesNotMatch(SEARCH_MODULE, /\.toLowerCase\(\)\.includes\(/)
})
/**
* 「不影响发布」:发布请求体里不许出现搜索词,发布范围也不许跟着过滤后的行数缩水。
* 服务端本来就按「小项 + 组别」发布所有已填行,前端这一层不许把搜索塞进去。
*/
test('发布请求里没有搜索词,范围也没跟着搜索结果缩水', () => {
const call = /await adminApi\.publish\(\{[\s\S]{0,220}?\}\)/.exec(ENTRY)?.[0] ?? ''
assert.notEqual(call, '找不到 adminApi.publish 调用')
assert.match(call, /eventId: selectedEventId\.value/)
assert.match(call, /categoryId: selectedCategoryId\.value \?\? undefined/)
assert.match(call, /rankOnly: rankOnlyOf\(finalPublish\.value\)/)
assert.doesNotMatch(call, /search|keyword|searchQuery|displayRows|visibleRows|pendingRows/)
// 待发布的行(按钮可用性与回执人数)仍按批次口径,不读过滤后的列表。
assert.match(ENTRY, /const pendingRows = computed\(\(\) =>\s*\n?\s*visibleRows\.value\.filter/)
assert.doesNotMatch(ENTRY, /pendingRows = computed\(\(\) =>\s*\n?\s*displayRows/)
})
/**
* 「不影响进度条」:分母与计数走 entryProgress({ rows: categoryRows, filter: selectedHeat }),
* 也就是**批次口径**;表格渲染那一份(displayRows)不许出现在它里面。
*/
test('进度条按批次口径计数,不读过滤后的列表', () => {
const progress = /const entryBatchProgress = computed\(\(\) =>[\s\S]*?\n\)/.exec(ENTRY)?.[0] ?? ''
assert.notEqual(progress, '找不到 entryBatchProgress')
assert.match(progress, /rows: categoryRows\.value/, '进度条的分母仍是整组 / 整批的行')
assert.match(progress, /filter: selectedHeat\.value/, '只按批次筛选')
assert.doesNotMatch(progress, /displayRows|searchQuery/)
// 工具栏的「已填 / 未录 / 有错」徽标同样是批次口径。
assert.match(ENTRY, /const stateCounts = computed\(\(\) => \{[\s\S]*?for \(const row of visibleRows\.value\)/)
})
test('表格渲染用的是 displayRows,其它口径仍是 visibleRows', () => {
// 两个列表的分工就写在这两行上:displayRows = 批次筛选之后再按搜索词过滤。
assert.match(
ENTRY,
/const displayRows = computed\(\(\) => filterEntryRows\(visibleRows\.value, searchQuery\.value\)\)/,
)
assert.match(ENTRY, /v-for="row in displayRows"/)
assert.doesNotMatch(ENTRY, /v-for="row in visibleRows"/)
// 勾选范围、预览算分与粘贴顺序仍是批次口径(搜索不改变任何写操作与算分的范围)。
assert.match(ENTRY, /const selectedRows = computed\(\(\) =>\s*\n?\s*visibleRows\.value\.filter/)
assert.doesNotMatch(ENTRY, /selectedRows = computed\(\(\) =>\s*\n?\s*displayRows/)
assert.match(ENTRY, /const score = computed\(\(\) => scoreWith\(visibleRows\.value, previewTeamRanks\.value\)\)/)
})
/** 「搜索不影响发布」这句话必须在界面上说清,而不是只写在注释里。 */
test('搜索框旁边常驻那句「不影响发布范围与进度」', () => {
assert.match(ENTRY, /\{\{ SEARCH_SCOPE_NOTE \}\}/)
assert.match(ENTRY, /:title="SEARCH_SCOPE_NOTE"/)
// 结果提示:找到 N 条 / 共 M 条,N 是命中数、M 是批次筛选后的总数。
assert.match(ENTRY, /searchCountText\(displayRows\.value\.length, visibleRows\.value\.length\)/)
assert.match(ENTRY, /\{\{ searchCountLabel \}\}/)
})
test('搜索框:输入即时过滤、清除按钮与 Esc 都接上了', () => {
assert.match(ENTRY, /const searchQuery = ref\(''\)/)
assert.match(ENTRY, /v-model="searchQuery"/, 'v-model 即时过滤')
assert.ok(ENTRY.includes('placeholder="搜姓名 / 号码布"'), '占位文案要写清搜的是姓名与号码布')
assert.match(ENTRY, /data-search-input/, 'Alt+Enter 的守卫要靠这个标记认出搜索框')
assert.match(ENTRY, /@keydown\.esc="clearSearch"/, 'Esc 清空')
const clear = /<button\s+v-if="searching"\s+class="entry-search-clear"[\s\S]*?<\/button>/.exec(ENTRY)?.[0] ?? ''
assert.notEqual(clear, '找不到清除按钮')
assert.match(clear, /@click="clearSearch"/)
assert.match(clear, /MxIcon name="close"/)
assert.match(ENTRY, /function clearSearch\(\): void \{\s*\n\s*searchQuery\.value = ''\s*\n\}/)
// 不加防抖:一屏几十到几百行,纯前端字符串匹配是瞬时的。
assert.doesNotMatch(ENTRY, /debounce|throttle|searchTimer/)
})
test('搜不到时给的是空状态而不是一张空表:回显关键字 + 一键清除', () => {
assert.match(ENTRY, /\{\{ searchEmptyLabel \}\}/)
assert.match(ENTRY, /\{\{ SEARCH_EMPTY_HINT \}\}/)
// 搜索空状态与「该批次没有报名项」是两条不同的提示,别互相盖住。
assert.match(
ENTRY,
/<tr v-if="!loading && searching && visibleRows\.length > 0 && displayRows\.length === 0">[\s\S]*?清除搜索[\s\S]*?<\/tr>/,
)
assert.match(ENTRY, /<tr v-if="!loading && visibleRows\.length === 0">/)
})
test('搜索框里的 Alt+Enter 不触发发布(页面级快捷键只加守卫,语义不变)', () => {
const handler = /function onPageKeydown\(event: KeyboardEvent\): void \{[\s\S]*?\n\}/.exec(ENTRY)?.[0] ?? ''
assert.notEqual(handler, '找不到 onPageKeydown')
assert.match(handler, /target\.dataset\.searchInput !== undefined/)
// 来源判断必须在 preventDefault 之前(否则搜索框里按下去已经被拦了)。
assert.ok(
handler.indexOf('dataset.searchInput') < handler.indexOf('preventDefault'),
'守卫要放在 preventDefault 之前',
)
// 既有快捷键的语义一个字都没动:仍是 Alt+Enter 发布、Ctrl+Enter 存草稿。
assert.match(handler, /if \(event\.altKey\) requestPublish\(\)/)
assert.match(handler, /else void saveDraft\(\)/)
// 只挡搜索框:成绩格里的 Alt+Enter 仍是发布(那是录入动作的一部分)。
assert.doesNotMatch(handler, /tagName === 'INPUT'|instanceof HTMLInputElement\) return/)
})
test('换小项 / 换组别时搜索归零(与批次筛选、行选择同一个理由)', () => {
const selectCategory = /function selectCategory\(id: number\): void \{[\s\S]*?\n\}/.exec(ENTRY)?.[0] ?? ''
assert.match(selectCategory, /clearSearch\(\)/)
assert.match(ENTRY, /selectedHeat\.value = 'all'\s*\n\s*selectedEntryIds\.value = \[\]\s*\n\s*(\/\/[^\n]*\n\s*)*clearSearch\(\)/)
})
test('高亮接在姓名的那个元素里,且没在搜索时渲染与改造前逐字相同', () => {
const cell = /<td>[\s\S]*?athlete-cell-text[\s\S]*?<\/td>/.exec(ENTRY)?.[0] ?? ''
assert.notEqual(cell, '找不到姓名单元格')
// 没在搜索:还是原来那一句(eng 限宽截断 / 展开按钮都挂在它上面)。
assert.match(cell, /<span v-if="!searching" class="athlete-cell-name">\{\{ athleteCellName\(row\) \}\}<\/span>/)
assert.match(
cell,
/highlightSegments\(athleteCellName\(row\), searchQuery\)/,
'搜索时才切成命中 / 未命中片段',
)
assert.match(cell, /<mark v-if="segment\.hit" class="entry-hit">/)
})
+6 -2
View File
@@ -124,10 +124,14 @@ test('页面的地址只来自 lanDisplayLinks(),不写死端口或路径', ()
assert.doesNotMatch(PAGE, /19540/)
})
test('二维码只给第一个地址,且用的是矢量 SVG', () => {
test('二维码:首条常驻一个,其余各条点按钮按需出码,都是矢量 SVG', () => {
// 首条常驻(位置、index === 0 的判据都没动);其余各条没有常驻码,点了才出——
// 「点空白处关闭 / 懒生成」那些口径在 lan-qr-toggle.test.ts 里。
assert.match(PAGE, /v-if="index === 0 && lanQrSvg !== null"/)
assert.match(PAGE, /v-if="openLanQrIp === item\.ip && openLanQrSvg !== null"/)
assert.match(PAGE, /import \{ encodeQr, qrToSvg \} from '@\/shared\/qrcode'/)
assert.match(PAGE, /qrToSvg\(first\.url, \{ scale, margin: QR_MARGIN, ecc: 'M' \}\)/)
// 编码参数(scale 由模块数反推、margin、ecc)一个字没变:常驻码与按需码共用这一处。
assert.match(PAGE, /qrToSvg\(url, \{ scale, margin: QR_MARGIN, ecc: 'M' \}\)/)
})
test('复制按钮走 copyText(含非安全上下文回退),失败有提示', () => {
+151
View File
@@ -0,0 +1,151 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import {
isLanQrKeepTarget,
LAN_QR_KEEP_SELECTOR,
showsQrButton,
toggleQr,
} from '../pages/lanDisplay'
/**
* 「非首条的临时二维码」的开关口径与接线。
*
* 需求(主人原话):卡片里只有第 1 条地址自动出码,余下各条要有个按钮点了临时出码、
* 点空白处自动关闭。三件事最容易写漏,所以各钉一处:
* 1. 状态推进(同一条再点=收起 / 点另一条=切过去)——纯函数,这里直接跑;
* 2. 懒生成——一个码是几百个 `<rect>`,渲染时为每条地址都算一次会把首屏拖慢;
* 3. 「点空白处关闭」的监听必须排除自己的按钮,否则点按钮会先关再开(按钮再也关不掉),
* 且必须有对应的摘除(这两个页面是长开窗口,漏摘会越积越多)。
*
* 页面级测试在仓库里跑不起来(没有 jsdom / @vue/test-utils),所以组件那半边退化成源码断言。
*/
const PAGE = readFileSync(new URL('../pages/DisplayPage.vue', import.meta.url), 'utf8')
/** 地址列表那一段模板(`v-for` 到 `</template>`),用来断言「渲染时不为每条地址生成二维码」。 */
function rowBlock(): string {
const start = PAGE.indexOf('v-for="(item, index) in lanLinks"')
const end = PAGE.indexOf('</template>', start)
return start < 0 || end < 0 ? '' : PAGE.slice(start, end)
}
test('toggleQr:没开时点一条=打开它(第 0 条与第 N 条都要能开)', () => {
assert.equal(toggleQr(null, 0), 0)
assert.equal(toggleQr(null, 3), 3)
assert.equal(toggleQr(null, '192.168.1.23'), '192.168.1.23')
})
test('toggleQr:同一条再点一次=收起', () => {
assert.equal(toggleQr(0, 0), null)
assert.equal(toggleQr(3, 3), null)
assert.equal(toggleQr('192.168.1.23', '192.168.1.23'), null)
})
test('toggleQr:点另一条=切过去,同一时刻只留一个码', () => {
assert.equal(toggleQr(1, 4), 4)
assert.equal(toggleQr(4, 1), 1)
// 状态里存的是「哪一条」,不是「各条各开不开」——所以不存在两个码同时开着的情况。
assert.equal(toggleQr('10.0.0.2', '10.0.0.3'), '10.0.0.3')
})
test('showsQrButton:首条不给按钮(它本来就有常驻码),其余都给', () => {
assert.equal(showsQrButton(0), false)
assert.equal(showsQrButton(1), true)
assert.equal(showsQrButton(9), true)
})
test('按钮:只有非首条出现,绑了点击,带 aria-expanded 与中文 title', () => {
const row = rowBlock()
assert.notEqual(row, '')
assert.match(row, /v-if="showsQrButton\(index\)"/)
assert.match(row, /class="mx-btn mx-btn-ghost mx-btn-sm"/)
assert.match(row, /@click="toggleLanQr\(item\.ip\)"/)
assert.match(row, /:aria-expanded="openLanQrIp === item\.ip"/)
assert.match(row, /title="点击显示二维码(点空白处关闭)"/)
})
test('懒生成:模板里不调编码器,只渲染「当前打开的那一条」', () => {
const row = rowBlock()
// v-for 里直接调 qrToSvg / encodeQr = 为每条地址都生成一个码,首屏会被几百个 rect 拖慢。
assert.doesNotMatch(row, /qrToSvg\(/)
assert.doesNotMatch(row, /encodeQr\(/)
assert.match(row, /v-if="openLanQrIp === item\.ip && openLanQrSvg !== null"/)
// 全页只有一处调 qrToSvg(首条常驻码与按需码共用的那个 helper),没有第二处在渲染时算码。
assert.equal((PAGE.match(/qrToSvg\(/g) ?? []).length, 1)
})
test('懒生成的 computed:没打开时一个码都不算', () => {
const openQr = /const openLanQrSvg = computed\(\(\) => \{[\s\S]*?\n\}\)/.exec(PAGE)?.[0] ?? ''
assert.notEqual(openQr, '', '找不到 openLanQrSvg')
assert.match(openQr, /if \(ip === null\) return null/)
assert.match(openQr, /find\(\(item\) => item\.ip === ip\)/)
// 那条地址被删掉(手填地址可以删)时也算不出码:不会渲染一个已经不在列表里的地址。
assert.match(openQr, /if \(link === undefined\) return null/)
})
test('关闭方式一:再点一次按钮=收起(走纯函数 toggleQr)', () => {
const toggle = /function toggleLanQr[\s\S]*?\n\}/.exec(PAGE)?.[0] ?? ''
assert.notEqual(toggle, '', '找不到 toggleLanQr')
assert.match(toggle, /toggleQr\(openLanQrIp\.value, ip\)/)
})
test('关闭方式二:Esc 挂在 document 上(不依赖按钮是否持有焦点)', () => {
const handler = /function onLanQrDismissKeydown[\s\S]*?\n\}/.exec(PAGE)?.[0] ?? ''
assert.notEqual(handler, '', '找不到 onLanQrDismissKeydown')
assert.match(handler, /event\.key === 'Escape'/)
assert.match(handler, /openLanQrIp\.value = null/)
assert.match(PAGE, /document\.addEventListener\('keydown', onLanQrDismissKeydown\)/)
})
test('关闭方式三:点空白处(document 上的 click,且排除自己的按钮与二维码容器)', () => {
// 必须是 click(而不是 pointerdown / mousedown):见页面里的注释——按下的那一刻收码会让卡片
// 当场变矮,码下方那些按钮在按住期间往上跳,浏览器就不会再派发 click,表现为「点了没反应」。
assert.match(PAGE, /document\.addEventListener\('click', onLanQrDismissClick\)/)
assert.doesNotMatch(PAGE, /addEventListener\('(pointerdown|mousedown|mouseup|touchstart)'/)
const handler = /function onLanQrDismissClick[\s\S]*?\n\}/.exec(PAGE)?.[0] ?? ''
assert.notEqual(handler, '', '找不到 onLanQrDismissClick')
assert.match(handler, /isLanQrKeepTarget\(target\)/)
assert.match(handler, /openLanQrIp\.value = null/)
// 按钮与二维码容器都要带记号:按钮自己负责开 / 关,容器内点击(长按识别、选中地址)也不算
// 「点空白处」。少了按钮上那个,document 会先把它关掉、按钮的处理函数再开一次。
// 注释里的解释文字不算数,先剥掉注释再数。
const row = rowBlock().replace(/<!--[\s\S]*?-->/g, '')
assert.equal((row.match(/data-lan-qr-keep/g) ?? []).length, 2, '按钮与二维码容器各一处')
assert.match(row, /class="lan-qr" data-lan-qr-keep/)
assert.match(row, /type="button"\s+data-lan-qr-keep/)
// 模板上的记号必须与判断里用的选择器是同一个名字:只改一边(比如把属性改名)会让排除
// 静默失效,而失效的表现是「按钮点了关不掉」,很难从代码上看出来。
const marker = LAN_QR_KEEP_SELECTOR.slice(1, -1)
assert.ok(marker.startsWith('data-'), `记号应当是一个 data 属性:${LAN_QR_KEEP_SELECTOR}`)
assert.ok(row.includes(marker), `模板里找不到 ${LAN_QR_KEEP_SELECTOR}`)
})
test('点空白处的排除判断:带记号的按钮 / 容器不放行,真空白处放行', () => {
// closest 是 DOM 接口,node 里没有;按结构注入假的即可把两条路都跑一遍。
const keep = { closest: (selector: string) => (selector === LAN_QR_KEEP_SELECTOR ? {} : null) }
assert.equal(isLanQrKeepTarget(keep), true)
// 空白处:closest 一路走到头都是 null(点的是卡片背景、别的卡片、页面空白)。
assert.equal(isLanQrKeepTarget({ closest: () => null }), false)
// target 为 null(事件没有目标)时按「不是自己的地盘」处理:该关就关——不能因为类型对不上
// 就让码留在屏幕上关不掉。
assert.equal(isLanQrKeepTarget(null), false)
})
test('监听按需挂、有挂就有摘(关掉时摘 + 组件卸载兜底摘)', () => {
assert.match(PAGE, /document\.removeEventListener\('click', onLanQrDismissClick\)/)
assert.match(PAGE, /document\.removeEventListener\('keydown', onLanQrDismissKeydown\)/)
// 挂 / 摘都由「码真的在屏幕上」驱动:点空白处、Esc、再点按钮、那条地址被删,任何一条关闭
// 路径都不必各自记得摘。
assert.match(PAGE, /watch\(\s*\(\) => openLanQrSvg\.value !== null,/)
const unmount = /onBeforeUnmount\(\(\) => \{[\s\S]*?\n\}\)/.exec(PAGE)?.[0] ?? ''
assert.match(unmount, /detachLanQrDismiss\(\)/)
})
test('首条那条常驻二维码原样保留', () => {
// 判据、位置、编码参数、那句话都没变:改的只是「其余各条怎么办」。
assert.match(PAGE, /v-if="index === 0 && lanQrSvg !== null" class="lan-qr"/)
assert.match(PAGE, /<div class="lan-qr-note">手机扫码直接打开<\/div>/)
assert.match(PAGE, /qrToSvg\(url, \{ scale, margin: QR_MARGIN, ecc: 'M' \}\)/)
const first = /const lanQrSvg = computed\(\(\) => \{[\s\S]*?\n\}\)/.exec(PAGE)?.[0] ?? ''
assert.match(first, /lanLinks\.value\[0\]/)
})
+126 -23
View File
@@ -2,8 +2,16 @@ import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import {
FINAL_OPTION_TITLE,
FINAL_PUBLISH_LABEL,
PREVIEW_RANK_ONLY_HINT,
PUBLISH_BUTTON_TITLE,
PUBLISH_OPTIONS,
RANK_ONLY_OPTION_TITLE,
boardModeNote,
defaultPublishChoice,
openPublishChoice,
publishChoiceIsFinal,
publishModeConfirm,
publishModeHint,
rankOnlyOf,
@@ -11,22 +19,77 @@ import {
} from '../pages/entryPublish'
/**
* 「以决赛成绩发布」勾选项的口径(主人的新能力):
* 不勾(默认)= 本次发布只更新名次,不计分、不发奖牌;
* 勾上 = 与改动前完全一样(算分、发牌、上榜)。
* 发布口径(只更新名次 / 按决赛成绩发布)。
* 本次改动:**录入页把常驻勾选框换成了「点发布 → 弹窗里二选一」**:
* 只更新名次(默认)= 本次发布只更新名次,不计分、不发奖牌;
* 按决赛成绩发布 = 与改动前完全一样(算分、发牌、上榜)。
*
* 两件事必须钉住:勾选状态真的进了请求(rankOnly),以及界面上任一处都不许把
* 「只更新名次」说成会算分(确认框 / 常驻提示 / 预览面板)。
* 三件事必须钉住:弹窗里选的那一项真的进了请求(rankOnly)、**每次打开弹窗都回到默认的
* 「只更新名次」**(不沿用上一次的选择),以及界面上任一处都不许把「只更新名次」说成会算分
* (弹窗选项 / 确认框正文 / 在榜口径提示 / 预览面板)。
*/
const SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
const MODULE = readFileSync(new URL('../pages/entryPublish.ts', import.meta.url), 'utf8')
const DIALOG = readFileSync(new URL('../components/ConfirmDialog.vue', import.meta.url), 'utf8')
test('默认不勾:勾选状态取反就是请求里的 rankOnly', () => {
test('选项 → 请求字段:只有「按决赛成绩发布」才计分', () => {
assert.equal(rankOnlyOf(publishChoiceIsFinal('rank-only')), true, '只更新名次 = 只写名次')
assert.equal(rankOnlyOf(publishChoiceIsFinal('final')), false, '按决赛成绩发布 = 计分')
// 这一层的布尔口径本身不能变:审核页的常驻勾选框还在用它。
assert.equal(rankOnlyOf(false), true, '不勾 = 只更新名次')
assert.equal(rankOnlyOf(true), false, '勾上 = 计分发布')
})
test('默认选中「只更新名次」,重开弹窗也不吃上一次的选择', () => {
assert.equal(defaultPublishChoice(), 'rank-only')
assert.equal(openPublishChoice(null), 'rank-only')
assert.equal(openPublishChoice('final'), 'rank-only', '上一次选过决赛也不能沿用')
assert.equal(openPublishChoice('rank-only'), 'rank-only')
})
test('两个选项的标题与说明都不是空串,且互不相同', () => {
assert.equal(PUBLISH_OPTIONS.length, 2)
assert.deepEqual(
PUBLISH_OPTIONS.map((option) => option.value),
['rank-only', 'final'],
'默认项在前',
)
for (const option of PUBLISH_OPTIONS) {
assert.notEqual(option.title.trim(), '', `${option.value} 的标题不能是空串`)
assert.notEqual(option.hint.trim(), '', `${option.value} 的说明不能是空串`)
}
assert.notEqual(PUBLISH_OPTIONS[0]?.title, PUBLISH_OPTIONS[1]?.title, '两个标题不能一样')
assert.notEqual(PUBLISH_OPTIONS[0]?.hint, PUBLISH_OPTIONS[1]?.hint, '两段说明不能一样')
// 原文就是主人点名的那两句。
assert.equal(RANK_ONLY_OPTION_TITLE, '只更新名次(不计分、不发奖牌)')
assert.equal(FINAL_OPTION_TITLE, '按决赛成绩发布(重算名次并计分、发奖牌、榜单跟着更新)')
// 说明复用现成的口径函数,不另写一套说法。
assert.equal(PUBLISH_OPTIONS[0]?.hint, publishModeHint(false))
assert.equal(PUBLISH_OPTIONS[1]?.hint, publishModeHint(true))
// 默认标记只有一个,且必须落在「只更新名次」那一项上。
assert.deepEqual(
PUBLISH_OPTIONS.filter((option) => option.badge !== '').map((option) => option.value),
['rank-only'],
)
})
test('录入页可见的文案里不再出现「勾选」——那是指向已删除的勾选框', () => {
// 审核页仍是常驻勾选框,FINAL_PUBLISH_LABEL / FINAL_PUBLISH_TITLE 归它用,不在此列。
const entryFacing = [
RANK_ONLY_OPTION_TITLE,
FINAL_OPTION_TITLE,
PUBLISH_BUTTON_TITLE,
PREVIEW_RANK_ONLY_HINT,
publishModeHint(false),
publishModeHint(true),
publishModeConfirm(false, 0),
publishModeConfirm(true, 0),
boardModeNote(3, 0) ?? '',
boardModeNote(3, 18) ?? '',
]
for (const text of entryFacing) assert.doesNotMatch(text, /勾选/, text)
})
test('确认框写明本次是计分发布还是只更新名次', () => {
const scored = publishModeConfirm(true, 0)
assert.match(scored, /按决赛成绩发布/)
@@ -50,13 +113,13 @@ test('只更新名次时,本组已有在榜积分必须点名说清「不会
assert.doesNotMatch(publishModeConfirm(true, 18), /保留/)
})
test('勾选框旁的常驻提示:不勾时明确说「不计分」', () => {
test('两种口径的说明:只更新名次时明确说「不计分」', () => {
assert.match(publishModeHint(false), /只更新名次/)
assert.match(publishModeHint(false), /不计分/)
assert.match(publishModeHint(true), /算分/)
})
test('勾选框文案本身就说清「勾选才计分发牌」', () => {
test('审核页的常驻勾选框文案不变:仍说清「勾选才计分发牌」', () => {
assert.match(FINAL_PUBLISH_LABEL, /决赛/)
assert.match(FINAL_PUBLISH_LABEL, /勾选才计分/)
})
@@ -85,28 +148,68 @@ test('在榜积分只数已发布的成绩,草稿不算', () => {
)
})
test('录入页:勾选框默认不勾,且勾选状态进了发布请求', () => {
assert.match(SOURCE, /const finalPublish = ref\(false\)/)
assert.match(SOURCE, /v-model="finalPublish"/)
assert.match(SOURCE, /rankOnly: rankOnlyOf\(finalPublish\.value\)/)
// 发布请求里不能写死 false(写死 = 永远计分发布,勾选框变成摆设)。
assert.doesNotMatch(SOURCE, /rankOnly: false/)
/**
* 下面这几条是**源码级接线断言**:`.vue` 跑不进 `node --test`,而「勾选框撤了、弹窗没接上」
* 这种改到一半的回归没有任何测试拦得住 —— 它既不是类型错误,也不影响构建。
*/
const TOOLBAR = SOURCE.slice(SOURCE.indexOf('<template #actions>'), SOURCE.indexOf('</PageHeader>'))
const PUBLISH_DIALOG = SOURCE.slice(
SOURCE.indexOf('<ConfirmDialog\n :open="publishDialog"'),
SOURCE.indexOf('</ConfirmDialog>', SOURCE.indexOf(':open="publishDialog"')),
)
test('录入页:工具栏上已经没有那个绑 finalPublish 的 input[type=checkbox]', () => {
assert.notEqual(TOOLBAR.length, 0, '工具栏那一段得能切出来')
assert.doesNotMatch(TOOLBAR, /type="checkbox"/)
assert.doesNotMatch(TOOLBAR, /finalPublish/)
assert.doesNotMatch(TOOLBAR, /publishChoice/)
// 撤掉的是勾选框与它旁边的徽标,发布按钮本身还在、还走同一个入口。
assert.doesNotMatch(SOURCE, /v-model="finalPublish"/)
assert.doesNotMatch(SOURCE, /publish-final-toggle/)
assert.doesNotMatch(SOURCE, /FINAL_PUBLISH_LABEL/)
assert.doesNotMatch(SOURCE, /FINAL_PUBLISH_TITLE/)
assert.doesNotMatch(SOURCE, /publishModeBadge/)
assert.match(TOOLBAR, /@click="requestPublish"/)
assert.match(TOOLBAR, /PUBLISH_BUTTON_TITLE/)
})
test('录入页:勾选框与醒目提示就贴在发布按钮边上', () => {
assert.match(SOURCE, /class="publish-final-toggle"/)
assert.match(SOURCE, /publishModeHint\(finalPublish\)/)
// 不勾时必须给出可读的「只更新名次」标记(警示色),不能只靠勾选框本身。
assert.match(SOURCE, /publishModeBadge/)
test('录入页:两个选项都挂在那个发布确认框里(不是第二个弹窗)', () => {
assert.notEqual(PUBLISH_DIALOG.length, 0, '确认框那一段得能切出来')
assert.match(PUBLISH_DIALOG, /v-for="option in PUBLISH_OPTIONS"/)
assert.match(PUBLISH_DIALOG, /v-model="publishChoice"/)
assert.match(PUBLISH_DIALOG, /type="radio"/)
assert.match(PUBLISH_DIALOG, /:value="option\.value"/)
assert.match(PUBLISH_DIALOG, /option\.title/)
assert.match(PUBLISH_DIALOG, /option\.hint/)
// 取消 = 什么都不发生(与改动前一致);确认才发。
assert.match(PUBLISH_DIALOG, /confirm-text="确认发布"/)
assert.match(PUBLISH_DIALOG, /@cancel="publishDialog = false"/)
// 挂得进去是因为 ConfirmDialog 开了默认插槽。
assert.match(DIALOG, /<slot><\/slot>/)
})
test('录入页:默认「只更新名次」,选中的口径真的进了发布请求', () => {
assert.match(SOURCE, /const publishChoice = ref<PublishChoice>\(defaultPublishChoice\(\)\)/)
assert.match(SOURCE, /const finalPublish = computed\(\(\) => publishChoiceIsFinal\(publishChoice\.value\)\)/)
assert.match(SOURCE, /publishChoice\.value = openPublishChoice\(publishChoice\.value\)/)
// 弹窗开着时再按一次 Alt+Enter 不能把选好的口径复位掉。
assert.match(SOURCE, /if \(publishDialog\.value\) return/)
assert.match(SOURCE, /rankOnly: rankOnlyOf\(finalPublish\.value\)/)
// 发布请求里不能写死 false(写死 = 永远计分发布,选项变成摆设)。
assert.doesNotMatch(SOURCE, /rankOnly: false/)
})
test('录入页:确认框用同一套文案,且把在榜积分传进去', () => {
assert.match(SOURCE, /publishModeConfirm\(finalPublish\.value, pointsOnBoard\.value\)/)
assert.match(SOURCE, /const publishDialogTitle = computed/)
})
test('录入页:实时积分预览要说明「不勾时这些分差不会发生」', () => {
assert.match(SOURCE, /preview-rank-only-hint/)
assert.match(MODULE, /勾选「以决赛成绩发布」/)
test('录入页:实时积分预览要说明「发布时选只更新名次时这些分差不会发生」', () => {
// 口径要等点发布才选,所以这句常驻显示,不能再挂在某个勾选状态上。
const line = /<p\b[^>]*preview-rank-only-hint[^>]*>/.exec(SOURCE)?.[0] ?? ''
assert.match(line, /preview-hint/)
assert.doesNotMatch(line, /v-if/)
assert.match(PREVIEW_RANK_ONLY_HINT, /发布时在弹出的窗口里选「只更新名次」/)
})
test('发布沿用旧路径:请求体仍带 eventId / categoryId / requestId', () => {
@@ -55,8 +55,10 @@ const eventNames = computed(() => new Map(events.value.map((event) => [event.id,
/**
* 「当前项目」由服务端判定(GET /api/display/live:人工指定 → 排期命中 → 最近发布)。
*
* 拉到过结论就用它(不受快照 20 行截断,auto/manual 都一样);还没成功过(老服务端、断网首屏、
* 示例数据)才退回「人工指定 + 快照分组」的兜底素材,有总比没有强。
* 拉到过结论就用它(这个接口按小项给**全量**的已发布成绩、**不设条数上限**,不受快照那个
* 「每组保底前 10 行、最多 8 组」窗口(`latestResultsGroupLimit` / `latestResultsRowsPerGroup`)限制,
* auto/manual 都一样);还没成功过(老服务端、断网首屏、示例数据)才退回「人工指定 + 快照分组」
* 的兜底素材,有总比没有强。
*/
const sources = computed<LiveEventSource[]>(() =>
liveFeed.value !== null
+383
View File
@@ -0,0 +1,383 @@
/**
* 大屏设备标签:把「这台机器是什么」压成一行,随 hello 上报给管理端列表。
*
* 现场问题:局域网浏览器打开 /display 时,管理端那一行只有服务端现生成的
* `display-a1b2c3d4`,操作员分不清哪台是二号楼的平板。这里用浏览器自己的 API 拼出
* `安卓 13 · Chrome 120 · 1080×2400 @3x · 手机` 这样一行,服务端只负责存与显示。
*
* 三个必须容错的地方(任一没兜住都会让标签变成 undefined 或干脆连不上):
* 1. `navigator.userAgentData` 只有 Chromium 系有,Safari / Firefox 只能解析 UA 字符串;
* 2. `getHighEntropyValues()` 是 Promise,**且要安全上下文**——内网 `http://192.168.x.x`
* 下会被直接拒绝,所以它只能用来「能拿到就锦上添花」,拿不到必须静默退回 UA 解析;
* 3. 测试环境里没有 `screen` / `navigator`,各段缺失时要整段跳过,不能出现空段。
*
* 纯逻辑,不依赖任何浏览器全局对象(`deviceLabelSource()` 是唯一的取值入口,注入即可测)。
*/
/** 标签最多这么多个字符(按码点算,不是 UTF-16 码元):管理端一行放不下更长的。 */
export const DEVICE_LABEL_MAX = 80
/** 高熵值只问这三项:型号、系统版本、完整浏览器版本;其余(架构等)对我们没用。 */
export const HIGH_ENTROPY_HINTS = ['model', 'platformVersion', 'uaFullVersion'] as const
/** 低熵上报的品牌项(`userAgentData.brands`)。 */
export interface UserAgentBrand {
brand: string
version: string
}
/** 高熵值(`getHighEntropyValues`)里我们会用到的几项。 */
export interface HighEntropyValues {
platform?: string
platformVersion?: string
uaFullVersion?: string
model?: string
}
/**
* `navigator.userAgentData` 的最小形状。
*
* 注意 `platformVersion` 在 Chrome 上属于**高熵**值(低熵只有 brands / mobile / platform),
* 内网 http 下拿不到——所以同步路径必须有 UA 解析兜底,异步路径再用它把
* 「UA 归约后的假系统版本」(Chrome 把所有 Android 10+ 都写成 `Android 10`)修正过来。
*/
export interface UserAgentDataLike {
platform?: string
platformVersion?: string
mobile?: boolean
brands?: UserAgentBrand[]
getHighEntropyValues?: (hints: string[]) => Promise<HighEntropyValues>
}
export interface ScreenLike {
width?: number
height?: number
}
/** 拼标签要用到的全部外部事实;全部可选,缺了就跳过对应那一段。 */
export interface DeviceLabelSource {
userAgent?: string
userAgentData?: UserAgentDataLike | null
screen?: ScreenLike | null
devicePixelRatio?: number
maxTouchPoints?: number
}
/** 一短一长两份输出:短的是列表主行,长的是原始 UA(悬浮提示 / 排障用)。 */
export interface DeviceLabel {
device: string
deviceRaw: string
}
/** 一段一段的标签;空串表示这一段没拿到,拼装时跳过。 */
export interface DeviceFields {
system: string
browser: string
screen: string
kind: string
}
export interface UserAgentParse {
system: string
kind: string
browser: string
}
/**
* 当前浏览器的事实来源。
*
* 整段包 try:某些隐私模式 / 嵌入 webview 里读 `navigator.userAgentData` 本身就可能抛,
* 而设备标签只是锦上添花,绝不能因此让大屏连不上。
*/
export function deviceLabelSource(): DeviceLabelSource {
try {
const nav = (globalThis as { navigator?: Navigator & { userAgentData?: UserAgentDataLike } })
.navigator
const screenLike = (globalThis as { screen?: ScreenLike & { width?: number } }).screen
if (nav === undefined) return {}
return {
userAgent: nav.userAgent,
userAgentData: nav.userAgentData ?? null,
screen: screenLike ?? null,
devicePixelRatio: (globalThis as { devicePixelRatio?: number }).devicePixelRatio,
maxTouchPoints: nav.maxTouchPoints,
}
} catch {
return {}
}
}
/** Windows NT 版本号 → 现场认得的名字;认不出(未来的 NT)就原样写版本号。 */
const WINDOWS_NT: Record<string, string> = {
'5.0': '2000',
'5.1': 'XP',
'5.2': 'XP',
'6.0': 'Vista',
'6.1': '7',
'6.2': '8',
'6.3': '8.1',
'10.0': '10',
'11.0': '11',
}
interface SystemGuess {
label: string
/** 这一段带具体版本号(`安卓 13`)还是只有名字(`macOS`);决定要不要被高熵值顶替。 */
versioned: boolean
/** UA 里的版本号被浏览器**故意归约**过(Chrome 把所有 Android 10+ 都写成 `Android 10`)。 */
reduced?: boolean
/**
* UA 里的这个名字比 UA-CH 更具体,不许被顶替。
*
* 目前只有 HarmonyOS:它的 UA 里同时写着 Android,而 UA-CH 的 platform 只会说 `Android`
* ——让 UA-CH 顶上去等于把华为设备降级成「安卓」。
*/
authoritative?: boolean
}
/**
* 只按 UA 字符串解析系统 / 形态 / 浏览器。
*
* iPadOS 13+ 的 UA 伪装成 macOS 桌面版(`Macintosh; Intel Mac OS X`),唯一的破绽是
* `maxTouchPoints > 1`(桌面 Mac 没有多点触摸),所以 isIpad 由调用方先算好传进来。
*/
export function parseUserAgent(
userAgent: string,
options: { maxTouchPoints?: number } = {},
): UserAgentParse {
const isIpad = isIpadUserAgent(userAgent, options.maxTouchPoints)
return {
system: uaSystem(userAgent, isIpad).label,
kind: kindLabel(userAgent, null, isIpad),
browser: browserLabel(userAgent, null),
}
}
/** 拼装前的四段:先取 UA(覆盖最广),再让 UA-CH 的可用信息顶替。 */
export function deviceFields(source: DeviceLabelSource): DeviceFields {
const userAgent = source.userAgent ?? ''
const userAgentData = source.userAgentData ?? null
const isIpad = isIpadUserAgent(userAgent, source.maxTouchPoints)
return {
system: systemLabel(userAgent, userAgentData, isIpad),
browser: browserLabel(userAgent, userAgentData),
screen: screenLabel(source),
kind: kindLabel(userAgent, userAgentData, isIpad),
}
}
/** 拼成一行:段序 系统 · 浏览器 · 分辨率 · 形态;空段直接跳过,不留 `undefined` 或双分隔符。 */
export function composeDeviceLabel(fields: DeviceFields, deviceRaw = ''): DeviceLabel {
const parts = [fields.system, fields.browser, fields.screen, fields.kind].filter(
(part) => part !== '',
)
return { device: truncateDeviceLabel(parts.join(' · ')), deviceRaw }
}
/** 同步版本:低熵 UA-CH + UA 解析。列表主行要立刻有名字,不能等异步。 */
export function deviceLabel(source: DeviceLabelSource): DeviceLabel {
return composeDeviceLabel(deviceFields(source), source.userAgent ?? '')
}
/**
* 异步版本:再问一次高熵值,拿到更准的系统 / 浏览器版本后重算。
*
* 只有 Chromium 系 + **安全上下文**(https 或 localhost)才拿得到;内网 http 会 reject,
* 方法不存在(Safari / Firefox)直接跳过——两种情况都退回同步结果,绝不能抛出去打断调用方。
* 差值通常就是 Android:UA 被归约成固定的 `Android 10`,真实版本只在 platformVersion 里。
*/
export async function resolveDeviceLabel(source: DeviceLabelSource): Promise<DeviceLabel> {
const base = deviceLabel(source)
const userAgentData = source.userAgentData ?? null
if (typeof userAgentData?.getHighEntropyValues !== 'function') return base
let values: HighEntropyValues
try {
values = await userAgentData.getHighEntropyValues([...HIGH_ENTROPY_HINTS])
} catch {
// 非安全上下文 / 用户拒绝:高熵值拿不到是常态,不是错误。
return base
}
if (values === null || typeof values !== 'object') return base
const merged: UserAgentDataLike = {
...userAgentData,
platform: values.platform ?? userAgentData.platform,
platformVersion: values.platformVersion ?? userAgentData.platformVersion,
}
return deviceLabel({ ...source, userAgentData: merged })
}
/** 超长截断:按**码点**切,`Array.from` 不会把代理对劈成半个字符(emoji 在 UA 里很常见)。 */
export function truncateDeviceLabel(value: string): string {
const chars = Array.from(value)
if (chars.length <= DEVICE_LABEL_MAX) return value
// 截断处补一个省略号,让管理端一眼看出「这名字是被截过的」,同时仍不超过上限。
return `${chars.slice(0, DEVICE_LABEL_MAX - 1).join('')}…`
}
function isIpadUserAgent(userAgent: string, maxTouchPoints?: number): boolean {
if (/iPad/i.test(userAgent)) return true
// iPadOS 13+ 的桌面版 UA:写着 Macintosh,但触摸点数暴露了它是触屏设备。
return /Macintosh/i.test(userAgent) && (maxTouchPoints ?? 0) > 1
}
function systemLabel(
userAgent: string,
userAgentData: UserAgentDataLike | null,
isIpad: boolean,
): string {
const fromUa = uaSystem(userAgent, isIpad)
const fromHints = hintSystem(userAgentData)
if (fromHints.label === '') return fromUa.label
if (fromUa.label === '') return fromHints.label
// UA-CH 带版本号,而 UA 没有版本号、或 UA 那一版**被浏览器归约过**(只有 Android 会)时以它为准:
// Chrome 把 Android 10+ 的 UA 一律写成 `Android 10`,真实系统版本只在 platformVersion 里。
// 反过来 Safari / Firefox 没有 UA-CH,只能靠 UA;Windows / macOS 的 UA-CH 也给不出更细的版本;
// HarmonyOS 是 UA 独有的名字(UA-CH 只会说 Android),标了 authoritative,不参与顶替。
if (fromUa.authoritative === true) return fromUa.label
if (fromHints.versioned && (fromUa.reduced === true || !fromUa.versioned)) return fromHints.label
return fromUa.label
}
function uaSystem(userAgent: string, isIpad: boolean): SystemGuess {
if (isIpad) {
const os = /OS (\d+)[._]/.exec(userAgent)
const version = os === null ? '' : majorVersion(os[1])
// 伪装成 macOS 的 iPadOS 拿不到系统版本(UA 里的 Version/ 是 Safari 的版本),
// 这时只说系统名,不编一个版本号出来。
return version === '' ? plain('iPadOS') : versioned(`iPadOS ${version}`)
}
// HarmonyOS 的 UA 里同时写着 Android,必须先认它,否则会被说成安卓;也不许被 UA-CH 顶替
//(UA-CH 只会说这台机器是 Android,比 UA 更粗)。
const harmony = /HarmonyOS(?:[ /](\d+(?:\.\d+)*))?/i.exec(userAgent)
if (harmony !== null) {
const version = harmony[1] === undefined ? '' : majorVersion(harmony[1])
if (version === '') return { label: 'HarmonyOS', versioned: false, authoritative: true }
return { label: `HarmonyOS ${version}`, versioned: true, authoritative: true }
}
const android = /Android[ /](\d+(?:\.\d+)*)/i.exec(userAgent)
if (android !== null) {
const version = majorVersion(android[1])
// Chrome 的 UA 归约:Android 10 及以上的机器在 UA 里都写着 `Android 10`(见 systemLabel 的取舍)。
return version === ''
? { label: '安卓', versioned: false, reduced: true }
: { label: `安卓 ${version}`, versioned: true, reduced: true }
}
const apple = /(?:iPhone|iPod)[^)]*\bOS (\d+)[._](\d+)/i.exec(userAgent)
if (apple !== null) return versioned(`iOS ${apple[1]}`)
const windows = /Windows NT (\d+\.\d+)/.exec(userAgent)
if (windows !== null) {
const name = WINDOWS_NT[windows[1]]
return versioned(name === undefined ? `Windows NT ${windows[1]}` : `Windows ${name}`)
}
// macOS 不带版本号:Safari 十几年如一日地写着 10_15_7,写出来只会误导。
if (/Macintosh|Mac OS X/i.test(userAgent)) return plain('macOS')
if (/Linux|X11|CrOS/i.test(userAgent)) return plain('Linux')
return plain('')
}
function hintSystem(userAgentData: UserAgentDataLike | null): SystemGuess {
const platform = typeof userAgentData?.platform === 'string' ? userAgentData.platform.trim() : ''
if (platform === '') return plain('')
const version = majorVersion(
typeof userAgentData?.platformVersion === 'string' ? userAgentData.platformVersion : '',
)
if (platform === 'Android') return version === '' ? plain('安卓') : versioned(`安卓 ${version}`)
if (platform === 'iOS') return version === '' ? plain('iOS') : versioned(`iOS ${version}`)
if (platform === 'Chrome OS') return plain('ChromeOS')
// macOS / Windows / Linux 等名字本身就能看懂,直接用它。
return plain(platform)
}
function kindLabel(
userAgent: string,
userAgentData: UserAgentDataLike | null,
isIpad: boolean,
): string {
if (isIpad) return '平板'
const mobile = typeof userAgentData?.mobile === 'boolean' ? userAgentData.mobile : null
if (/Android/i.test(userAgent)) {
// Android 平板的两种写法:显式 Tablet,或者干脆不带 Mobile(手机版一定带)。
// UA-CH 的 mobile 是同一件事的权威说法,与 UA 冲突时以它为准(UA 可能被改写过)。
if (mobile === false) return '平板'
return /Tablet/i.test(userAgent) || !/Mobile/i.test(userAgent) ? '平板' : '手机'
}
if (/iPhone|iPod/i.test(userAgent)) return '手机'
// UA 认不出形态(桌面浏览器)时还能问一句 UA-CH;两者都没有就什么都不说,不猜。
if (mobile === true) return '手机'
if (userAgent !== '' || mobile === false) return '桌面'
return ''
}
function browserLabel(userAgent: string, userAgentData: UserAgentDataLike | null): string {
// 顺序即优先级:微信、UC 的 UA 里同时写着 Chrome / Safari,必须先认出来;
// Edge 的 UA 里同时写着 Chrome,Chrome 的 UA 里同时写着 Safari。
const wechat = /MicroMessenger[/ ](\d+(?:\.\d+)*)/i.exec(userAgent)
if (wechat !== null) return nameWithVersion('微信', wechat[1])
const uc = /UCBrowser[/ ](\d+(?:\.\d+)*)/i.exec(userAgent)
if (uc !== null) return nameWithVersion('UC', uc[1])
const edge = /Edg[a-z]*[/ ]v?(\d+(?:\.\d+)*)/i.exec(userAgent)
if (edge !== null) return nameWithVersion('Edge', edge[1])
const chrome = /(?:Chrome|CriOS)[/ ](\d+(?:\.\d+)*)/i.exec(userAgent)
if (chrome !== null) return nameWithVersion('Chrome', chrome[1])
const firefox = /(?:Firefox|FxiOS)[/ ](\d+(?:\.\d+)*)/i.exec(userAgent)
if (firefox !== null) return nameWithVersion('Firefox', firefox[1])
const safari = /Version[/ ](\d+(?:\.\d+)*)[\s\S]*Safari/i.exec(userAgent)
if (safari !== null) return nameWithVersion('Safari', safari[1])
if (/Safari/i.test(userAgent)) return 'Safari'
return brandLabel(userAgentData)
}
/** UA 里认不出浏览器时,退回低熵的 `brands`(Chromium 一定带)。 */
function brandLabel(userAgentData: UserAgentDataLike | null): string {
const brands = Array.isArray(userAgentData?.brands) ? userAgentData.brands : []
// 「Not_A Brand」是规范要求的占位品牌,Chromium 是同一内核的马甲,都排在真名之后。
const usable = brands.filter((item) => !/Not.?A.?Brand/i.test(item.brand))
const real = usable.filter((item) => !/^Chromium$/i.test(item.brand))
const picked = (real.length > 0 ? real : usable)[0]
if (picked === undefined) return ''
const name = /^Google Chrome$/i.test(picked.brand) ? 'Chrome' : picked.brand
return nameWithVersion(name, picked.version)
}
function screenLabel(source: DeviceLabelSource): string {
const width = positiveInt(source.screen?.width)
const height = positiveInt(source.screen?.height)
if (width === null || height === null) return ''
const dpr = source.devicePixelRatio
// 倍率 ≤1(桌面常见的 1x、缩放后的 0.9x)不写:每台机器都拖一个没信息量的后缀。
if (typeof dpr !== 'number' || !Number.isFinite(dpr) || dpr <= 1) return `${width}×${height}`
return `${width}×${height} @${trimNumber(dpr)}x`
}
function positiveInt(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null
return Math.round(value)
}
/** 保留两位小数并去掉尾随 0:安卓报的 2.625 写成 `2.63x`,整数倍率写成 `3x`。 */
function trimNumber(value: number): string {
return String(Math.round(value * 100) / 100)
}
/** 取主版本号;`0` / 取不到都按「没有版本」处理,免得写出 `安卓 0`。 */
function majorVersion(value: string): string {
const match = /^(\d+)/.exec(value.trim())
if (match === null) return ''
const parsed = Number.parseInt(match[1] ?? '', 10)
return Number.isFinite(parsed) && parsed > 0 ? String(parsed) : ''
}
function nameWithVersion(name: string, version: string): string {
const major = majorVersion(version)
return major === '' ? name : `${name} ${major}`
}
function plain(label: string): SystemGuess {
return { label, versioned: false }
}
function versioned(label: string): SystemGuess {
return { label, versioned: true }
}
+136
View File
@@ -0,0 +1,136 @@
/**
* 局域网大屏的**稳定实例 id**。
*
* 修的 bug:局域网浏览器打开 `/display` 时 URL 里没有 `instance` / `screen`,于是 hello
* 的 `displayKey` / `instanceId` 都是空的,服务端每收到一次 hello 就现生成一个
* `display-<随机>`;而 `RealtimeClient.setPage()` 在翻页后会**再发一次 hello** —— 老连接那条
* 实例就永远留在注册表里变成「离线」条目(管理端列表里凭空多出一条该设备的离线记录,
* TTL 十分钟)。让 id 在本机稳定下来,重发 hello 就只是刷新同一条记录。
*
* 取舍(主人已确认,不做额外复杂度):**同一个浏览器开两个标签页会合并成一个条目**,
* 后上报的那页覆盖前一个的页面 / 心跳。现场一块屏就是一个标签页,不值得为它引入 session 级
* 存储或 BroadcastChannel。
*
* 另一条底线:localStorage 不可用(隐私模式、配额满、属性读取本身抛异常)时**静默退回今天的
* 行为**(交给服务端生成),绝不能因为存储问题让大屏连不上。
*/
/** 本机实例 id 的前缀:服务端一眼能看出这不是 Console 带来的 id。 */
export const DISPLAY_INSTANCE_ID_PREFIX = 'browser-'
/** 实例 id 在 localStorage 里的键。带前缀,免得和同域下别的页面撞。 */
export const DISPLAY_INSTANCE_STORAGE_KEY = 'scoreboard.display.instance'
/** 存下来的 id 最长这么多字符;超过一律当成脏数据重生成。 */
const MAX_STORED_ID_LENGTH = 64
/** 合法 id:我们自己生成的、或 Console 那种主机名式 id 都落在 ASCII 可见字符里。 */
const STORED_ID_PATTERN = /^[A-Za-z0-9._:-]+$/
/** localStorage 的最小接口;注入假实现后,node 里也能把存取与脏数据这两条路走一遍。 */
export interface InstanceIdStorage {
getItem(key: string): string | null
setItem(key: string, value: string): void
}
/** 浏览器 localStorage;隐私模式下**读属性本身就抛**,所以整段包 try。 */
export function instanceIdStorage(): InstanceIdStorage | null {
try {
return (globalThis as { localStorage?: InstanceIdStorage }).localStorage ?? null
} catch {
return null
}
}
export interface DisplayUrlIdentity {
/** URL 里的 `instance`(Console 打开的窗口才有)。 */
instance?: string | null
/** URL 里的 `screen`(Console 带下来的屏号)。 */
screen?: string | null
}
/** hello 要用的三个值;`browserId` 只在局域网浏览器(没有 URL 参数)时存在。 */
export interface DisplayIdentity {
/** hello 的 displayKey:URL 里有就沿用 URL 的,否则用本机稳定 id。 */
displayKey: string | undefined
/**
* hello 的 instanceId。
*
* **只有** URL 带 `instance`(Console 窗口)或走本机稳定 id 时才上报:只带 `screen`
* 的窗口维持升级前的行为(服务端按 displayKey 认它),免得凭空多出一套认法。
*/
instanceId: string | undefined
/** 本机稳定 id(局域网浏览器才有)。 */
browserId: string | undefined
}
/**
* 解析实例 id:URL 参数优先,其次本机持久化的那个(没有就生成并写回)。
*
* 返回 undefined = 交给服务端生成(今天的行为)——存储不可用、或调用方压根没给 storage。
* 注意这里把空串当「没给」:`?instance=` 这种残缺链接以前会让 displayKey / instanceId 都变成
* 空串,服务端照样现生成一个(正是本文件要修的那个 bug)。
*/
export function resolveInstanceId(
url: DisplayUrlIdentity,
storage: InstanceIdStorage | null,
randomId: () => string = randomBrowserId,
): string | undefined {
const fromUrl = nonEmpty(url.instance) ?? nonEmpty(url.screen)
if (fromUrl !== undefined) return fromUrl
if (storage === null) return undefined
try {
const saved = normalizeStoredId(storage.getItem(DISPLAY_INSTANCE_STORAGE_KEY))
if (saved !== null) return saved
const created = `${DISPLAY_INSTANCE_ID_PREFIX}${randomId()}`
storage.setItem(DISPLAY_INSTANCE_STORAGE_KEY, created)
return created
} catch {
// 隐私模式:读或写抛异常都按「存不了」处理,退回服务端生成,绝不因此连不上。
return undefined
}
}
/** 拆出 hello 要的三个值;Console 窗口(URL 带 instance / screen)的行为与升级前完全一致。 */
export function resolveDisplayIdentity(
url: DisplayUrlIdentity,
storage: InstanceIdStorage | null,
randomId: () => string = randomBrowserId,
): DisplayIdentity {
const instance = nonEmpty(url.instance)
const screen = nonEmpty(url.screen)
if (instance !== undefined) {
return { displayKey: instance, instanceId: instance, browserId: undefined }
}
if (screen !== undefined) {
return { displayKey: screen, instanceId: undefined, browserId: undefined }
}
const browserId = resolveInstanceId(url, storage, randomId)
return { displayKey: browserId, instanceId: browserId, browserId }
}
/** 生成 `browser-3f2a1c9d` 这样的 id;crypto 拿不到就退回 Math.random(够用:只要本机不重复)。 */
export function randomBrowserId(): string {
const cryptoLike = (globalThis as { crypto?: Crypto }).crypto
if (typeof cryptoLike?.getRandomValues === 'function') {
const buffer = new Uint8Array(4)
cryptoLike.getRandomValues(buffer)
return Array.from(buffer, (byte) => byte.toString(16).padStart(2, '0')).join('')
}
return Math.random().toString(16).slice(2, 10).padEnd(8, '0')
}
/** 空串 / 纯空白 / null 一律当成「没给」。 */
function nonEmpty(value: string | null | undefined): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed === '' ? undefined : trimmed
}
/** 存下来的值可能被手工改过、或是别的版本写的:认不出就当没有,重生成一个。 */
function normalizeStoredId(value: string | null): string | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
if (trimmed === '' || trimmed.length > MAX_STORED_ID_LENGTH) return null
return STORED_ID_PATTERN.test(trimmed) ? trimmed : null
}
+2 -1
View File
@@ -97,7 +97,8 @@ export function pickLiveColumns(events: readonly LiveEventSource[]): LiveColumn[
* 接口还没成功过时的兜底素材:人工指定的 id + 快照 latestResults 里能拿到的组。
*
* 只在老服务端(没有 /api/display/live)、断网首屏、示例数据这三种情况下走这条路;
* 快照的 latestResults 受 20 行上限约束,所以它只能算「有总比没有强」。
* 快照的 latestResults 窗口是**每组保底前 10 行、最多 8 组**(`realtime/snapshot.go` 的
* `latestResultsGroupLimit` / `latestResultsRowsPerGroup`),所以它只能算「有总比没有强」。
* 快照里没有该项目时保留这一栏(groups 为空),由页面显示占位而不是静默少一栏。
*/
export function fallbackLiveSources(
+3 -2
View File
@@ -77,8 +77,9 @@ export interface EventResultColumn {
* 第 1 名不在先到的那 6 行里就整行不显示、剩下的名次看着是跳的(3、6、9、14、15、16)。
* 名次只能由名次自己说了算(实时赛况页的 liveRows 同一套口径)。
*
* ⚠️ 服务端**根本没下发**的行补不回来:那是快照 20 行窗口(`realtime/snapshot.go` 的
* `latestResultsLimit`)截断的问题,不在这层;这一层只保证「拿到的行按名次画」。
* ⚠️ 服务端**根本没下发**的行补不回来:那是快照「每组保底前 10 行、最多 8 组」窗口
* (`realtime/snapshot.go` 的 `latestResultsGroupLimit` / `latestResultsRowsPerGroup`)截断的问题,
* 不在这层;这一层只保证「拿到的行按名次画」。
*
* 并列名次保持原有先后(`sort` 在 ES2019 起是稳定的):服务端按发布时间给出的并列行
* 不许在这里被换位置,否则前两栏的观感就变了。无名次(延迟结算)的行排最后。
+233
View File
@@ -0,0 +1,233 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
DEVICE_LABEL_MAX,
composeDeviceLabel,
deviceFields,
deviceLabel,
parseUserAgent,
resolveDeviceLabel,
truncateDeviceLabel,
type DeviceLabelSource,
type HighEntropyValues,
type UserAgentDataLike,
} from '../deviceLabel'
/**
* 设备标签:管理端「在线大屏」列表的主行。
*
* 现场问题是一串 `display-a1b2c3d4` 分不清哪台是哪台,所以这段解析必须**认得出现场那几种设备**
* (安卓手机、iPad、Windows 桌面、微信内置浏览器),并且在只有 UA、或只有低熵 UA-CH 的机器上
* 都要给出人话;`getHighEntropyValues` 在内网 http 下会被拒绝,那条路必须静默退回。
*/
const ANDROID_CHROME_UA =
'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'
const IPAD_SAFARI_UA =
'Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1'
/** iPadOS 13+ 的桌面版 UA:和真 macOS 一模一样,只有触摸点数能区分。 */
const IPAD_DESKTOP_MODE_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'
const WINDOWS_EDGE_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.2210.91'
const LINUX_FIREFOX_UA = 'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0'
const WECHAT_ANDROID_UA =
'Mozilla/5.0 (Linux; Android 13; 2211133C Build/TKQ1.221013.002; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/112.0.5615.136 Mobile Safari/537.36 MicroMessenger/8.0.49.2600(0x2800313D) WeChat/arm64 Weixin NetType/WIFI Language/zh_CN ABI/arm64'
const WECHAT_IOS_UA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.40(0x18002832) NetType/WIFI Language/zh_CN'
/** 低熵 UA-CH:Chromium 在**非安全上下文**下也只有这三个字段(platform 没有版本号)。 */
function uaDataLow(platform: string, mobile: boolean): UserAgentDataLike {
return { platform, mobile, brands: [{ brand: 'Chromium', version: '120' }] }
}
test('安卓手机(Chrome):UA 里的 Android 10 是归约后的假版本,高熵值才给出 13', async () => {
const source: DeviceLabelSource = {
userAgent: ANDROID_CHROME_UA,
userAgentData: {
...uaDataLow('Android', true),
getHighEntropyValues: async (): Promise<HighEntropyValues> => ({
platform: 'Android',
platformVersion: '13.0.0',
uaFullVersion: '120.0.6099.230',
model: 'Pixel 7',
}),
},
screen: { width: 1080, height: 2400 },
devicePixelRatio: 3,
}
assert.equal(deviceLabel(source).device, '安卓 10 · Chrome 120 · 1080×2400 @3x · 手机')
assert.equal((await resolveDeviceLabel(source)).device, '安卓 13 · Chrome 120 · 1080×2400 @3x · 手机')
})
test('userAgentData 存在但 getHighEntropyValues 被拒(内网 http):退回 UA 解析,不抛异常', async () => {
const source: DeviceLabelSource = {
userAgent: ANDROID_CHROME_UA,
userAgentData: {
...uaDataLow('Android', true),
getHighEntropyValues: async () => {
throw new DOMException('Not allowed', 'SecurityError')
},
},
screen: { width: 1080, height: 2400 },
devicePixelRatio: 3,
}
assert.equal((await resolveDeviceLabel(source)).device, '安卓 10 · Chrome 120 · 1080×2400 @3x · 手机')
})
test('getHighEntropyValues 不存在(Safari / Firefox)时直接跳过,不调用也不报错', async () => {
const source: DeviceLabelSource = {
userAgent: LINUX_FIREFOX_UA,
userAgentData: uaDataLow('Linux', false),
screen: { width: 1920, height: 1080 },
devicePixelRatio: 1,
}
assert.equal((await resolveDeviceLabel(source)).device, 'Linux · Firefox 121 · 1920×1080 · 桌面')
})
test('iPad Safari:真 iPad 报 iPadOS 17 + 平板', () => {
const label = deviceLabel({
userAgent: IPAD_SAFARI_UA,
screen: { width: 2048, height: 2732 },
devicePixelRatio: 2,
maxTouchPoints: 5,
})
assert.equal(label.device, 'iPadOS 17 · Safari 17 · 2048×2732 @2x · 平板')
})
test('iPadOS 13+ 伪装成 macOS:靠 maxTouchPoints 识破,判成 iPadOS / 平板', () => {
const ipad = deviceLabel({
userAgent: IPAD_DESKTOP_MODE_UA,
screen: { width: 2048, height: 2732 },
devicePixelRatio: 2,
maxTouchPoints: 5,
})
// 伪装版拿不到系统版本(UA 里的 Version/ 是 Safari 的版本),只说系统名,不编版本号。
assert.equal(ipad.device, 'iPadOS · Safari 17 · 2048×2732 @2x · 平板')
// 同一串 UA、触摸点数为 0:这才是真 macOS 桌面,绝不能被判成 iPad。
const mac = deviceLabel({ userAgent: IPAD_DESKTOP_MODE_UA, maxTouchPoints: 0 })
assert.equal(mac.device, 'macOS · Safari 17 · 桌面')
})
test('Windows 桌面 Edge:Edge 抢在 Chrome 前面(Edge 的 UA 里也写着 Chrome)', () => {
const label = deviceLabel({
userAgent: WINDOWS_EDGE_UA,
userAgentData: uaDataLow('Windows', false),
screen: { width: 1920, height: 1080 },
devicePixelRatio: 1,
})
assert.equal(label.device, 'Windows 10 · Edge 120 · 1920×1080 · 桌面')
})
test('Linux 桌面 Firefox:没有 UA-CH 也认得出来', () => {
assert.deepEqual(parseUserAgent(LINUX_FIREFOX_UA), {
system: 'Linux',
kind: '桌面',
browser: 'Firefox 121',
})
})
test('微信内置浏览器:微信版本在前,系统与形态照常给出', () => {
assert.equal(
deviceLabel({
userAgent: WECHAT_ANDROID_UA,
screen: { width: 1080, height: 2340 },
devicePixelRatio: 2.75,
}).device,
'安卓 13 · 微信 8 · 1080×2340 @2.75x · 手机',
)
assert.equal(deviceLabel({ userAgent: WECHAT_IOS_UA }).device, 'iOS 16 · 微信 8 · 手机')
})
test('UA 与 UA-CH 都没有时给空标签,不编造内容', () => {
const label = deviceLabel({})
assert.equal(label.device, '')
assert.equal(label.deviceRaw, '')
assert.deepEqual(parseUserAgent(''), { system: '', kind: '', browser: '' })
})
test('各段缺失时整段跳过:不出现空段、双分隔符或 undefined', () => {
// 只有 UA:没有分辨率那一段。
const noScreen = deviceLabel({ userAgent: LINUX_FIREFOX_UA })
assert.equal(noScreen.device, 'Linux · Firefox 121 · 桌面')
assert.doesNotMatch(noScreen.device, / · · |undefined|null/)
// 只有 UA-CH 的 mobile:系统与浏览器都没有,只剩形态。
assert.equal(deviceLabel({ userAgentData: { mobile: true } }).device, '手机')
// 只有分辨率:小数点后两位以内,且不带倍率前缀。
const fields = composeDeviceLabel({ system: '', browser: '', screen: '1920×1080', kind: '' })
assert.equal(fields.device, '1920×1080')
assert.equal(composeDeviceLabel({ system: '', browser: '', screen: '', kind: '' }).device, '')
// screen 存在但值不合理(0 / NaN)时也不留空段。
assert.equal(deviceLabel({ screen: { width: 0, height: 1080 } }).device, '')
assert.equal(deviceLabel({ screen: { width: Number.NaN, height: 1080 } }).device, '')
})
test('形态识别:Android 平板没有 Mobile 字样,UA-CH 的 mobile 兜底其余情况', () => {
const tabletUa =
'Mozilla/5.0 (Linux; Android 12; SM-X200 Build/SP1A.210812.016) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36'
assert.equal(parseUserAgent(tabletUa).kind, '平板')
const tabletExplicit =
'Mozilla/5.0 (Linux; Android 12; SM-X200 Tablet) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36'
assert.equal(parseUserAgent(tabletExplicit).kind, '平板')
// Android 手机:UA 里有 Mobile,UA-CH 也说它是移动设备。
assert.equal(deviceFields({ userAgent: ANDROID_CHROME_UA, userAgentData: { mobile: true } }).kind, '手机')
// UA-CH 与 UA 冲突时以 UA-CH 为准(UA 可能被改写过):mobile=false → 平板。
assert.equal(deviceFields({ userAgent: tabletExplicit, userAgentData: { mobile: false } }).kind, '平板')
// UA 里明写着 Tablet 时仍然是平板(那是设备自己说的,比 mobile 标志更具体)。
assert.equal(deviceFields({ userAgent: tabletExplicit, userAgentData: { mobile: true } }).kind, '平板')
// UA 完全没有形态线索时才靠 mobile;UA 与 UA-CH 都没线索时不猜。
assert.equal(deviceFields({ userAgentData: { mobile: true } }).kind, '手机')
assert.equal(deviceFields({ userAgentData: { mobile: false } }).kind, '桌面')
assert.equal(deviceFields({ userAgentData: {} }).kind, '')
assert.equal(deviceFields({ userAgent: WINDOWS_EDGE_UA }).kind, '桌面')
})
test('HarmonyOS 不会被说成安卓,UC 与旧式 Edge 也认得出来', () => {
const harmonyUa =
'Mozilla/5.0 (Linux; Android 10; HarmonyOS; ALN-AL00; HMSCore 6.13.0.302) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36'
assert.equal(parseUserAgent(harmonyUa).system, 'HarmonyOS')
// 华为的浏览器有 UA-CH,而它的 platform 只会说 Android(比 UA 更粗):不能被顶替成「安卓 13」。
assert.equal(
deviceLabel({ userAgent: harmonyUa, userAgentData: { platform: 'Android', platformVersion: '13.0.0' } })
.device,
'HarmonyOS · Chrome 114 · 手机',
)
const ucUa =
'Mozilla/5.0 (Linux; U; Android 11; zh-CN; M2012K11AC Build/RKQ1.200826.002) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/78.0.3904.108 UCBrowser/13.4.2.1307 Mobile Safari/537.36'
assert.equal(parseUserAgent(ucUa).browser, 'UC 13')
const oldEdgeUa =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edge/18.19045'
assert.equal(parseUserAgent(oldEdgeUa).browser, 'Edge 18')
})
test('标签超过 80 字符会截断,且不会切出半个多字节字符', () => {
// 认不出的平台名会原样进标签,拿它把标签撑到超过上限(含代理对,专门测「半个字符」)。
const longPlatform = '😀'.repeat(100)
const label = deviceLabel({ userAgentData: { platform: longPlatform } })
assert.equal(Array.from(label.device).length, DEVICE_LABEL_MAX)
assert.ok(label.device.endsWith('…'), '截断处要有省略号,让人看出这名字被截过')
assert.doesNotMatch(label.device, /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/)
// 刚好到上限时不截断。
const exact = '中'.repeat(DEVICE_LABEL_MAX)
assert.equal(truncateDeviceLabel(exact), exact)
assert.equal(truncateDeviceLabel(`${exact}中`), `${'中'.repeat(DEVICE_LABEL_MAX - 1)}…`)
})
test('deviceRaw 原样带上原始 UA,给悬浮提示排障用', () => {
const label = deviceLabel({ userAgent: WINDOWS_EDGE_UA, screen: { width: 1920, height: 1080 } })
assert.equal(label.deviceRaw, WINDOWS_EDGE_UA)
})
@@ -0,0 +1,70 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
/**
* 接线钉子:设备标签与改名的「最后一公里」。
*
* 大屏(`.vue` / `useScoreboard.ts`)在仓库里跑不进测试(没有 jsdom / @vue/test-utils),
* 所以纯逻辑测完还必须钉住「真的接上了」—— 少了这一步,最典型的半成品长这样:
* 解析函数写得漂漂亮亮、管理端也渲染了 alias,但 hello 里压根没带 device,
* 或者列表还在渲染实例 id 而没接上 instanceDisplayName()。两者都编译得过、单测也全绿。
*/
const USE_SCOREBOARD = readFileSync(new URL('../useScoreboard.ts', import.meta.url), 'utf8')
const DISPLAY_PAGE = readFileSync(new URL('../../admin/pages/DisplayPage.vue', import.meta.url), 'utf8')
test('useScoreboard 的 hello 带上 device / deviceRaw', () => {
assert.match(USE_SCOREBOARD, /device: label\.device/, 'hello 必须带上设备标签')
assert.match(USE_SCOREBOARD, /deviceRaw: label\.deviceRaw/, 'hello 必须带上原始 UA')
assert.match(USE_SCOREBOARD, /const label = deviceLabel\(deviceLabelSource\(\)\)/, '标签要走设备解析层')
})
test('useScoreboard 用本机稳定实例 id,并在高熵值到达后补齐标签', () => {
// 实例 id 稳定化:不稳定的 id 会让 setPage 补发的那条 hello 在服务端留下离线残影。
assert.match(USE_SCOREBOARD, /resolveDisplayIdentity\(/)
assert.match(USE_SCOREBOARD, /instanceIdStorage\(\)/, '必须从 localStorage 取本机稳定 id')
assert.doesNotMatch(
USE_SCOREBOARD,
/instanceId: options\.value\.instance \?\? undefined/,
'不能再把「URL 没带 instance」直接当成没有实例 id',
)
assert.match(USE_SCOREBOARD, /client\?\.setDevice\(refined\.device, refined\.deviceRaw\)/)
assert.match(USE_SCOREBOARD, /void refineDeviceLabel\(\)/)
})
test('管理端列表渲染别名 / 设备标签,并把 id 降级成小字', () => {
assert.match(DISPLAY_PAGE, /\{\{ instanceDisplayName\(instance\) \}\}/, '主行名称要走名称优先级')
assert.match(DISPLAY_PAGE, /v-if="instanceIdNote\(instance\) !== null"/, 'id 降级成小字,仅在有别名/标签时显示')
assert.match(DISPLAY_PAGE, /:title="instanceTooltip\(instance\)"/, '悬浮提示要给排障信息')
assert.doesNotMatch(
DISPLAY_PAGE,
/<span class="instance-id">\{\{ instance\.id \}\}<\/span>/,
'主行不能再直接渲染实例 id',
)
})
test('管理端有改名入口:可写才可点、保存调接口、失败给中文报错', () => {
assert.match(DISPLAY_PAGE, /@click="startRename\(instance\)"/, '每行要有改名按钮')
assert.match(DISPLAY_PAGE, /:disabled="!canWrite \|\| renamePending !== null"/, '只读访客不能改名')
assert.match(DISPLAY_PAGE, /renameGateReason \?\? ALIAS_RENAME_HINT/, '禁用时要写清原因')
assert.match(DISPLAY_PAGE, /adminApi\.displayAlias\(instance\.id, alias\)/, '保存要走别名接口')
assert.match(DISPLAY_PAGE, /aliasPayload\(renameDraft\.value\)\.alias/, '提交前统一 trim')
assert.match(DISPLAY_PAGE, /@click="clearAlias\(instance\)"/, '要有「清除」入口回到自动标签')
// 保存成功后必须刷新列表(服务端记录才是权威),失败要把原文交给 toast。
assert.match(DISPLAY_PAGE, /await adminApi\.displayAlias[\s\S]{0,200}?await refreshInstances\(\)/)
assert.match(DISPLAY_PAGE, /toast\.error\('改名失败'/)
})
test('原有的徽标 / 当前页 / 来源 / 心跳 / 切页控件一个都没丢', () => {
for (const anchor of [
/\{\{ instance\.online \? '在线' : '离线' \}\}/,
/v-if="instance\.slow" class="mx-badge mx-badge-warning">落后</,
/instance\.screen === '' \? '局域网浏览器' : `屏 \$\{instance\.screen\}`/,
/当前页:\{\{ instance\.page === '' \? '未上报' : pageLabel\(instance\.page\) \}\}/,
/来源:\{\{ instance\.remoteAddr === '' \? '未知' : instance\.remoteAddr \}\}/,
/最后心跳:\{\{ formatDateTime\(instance\.lastHeartbeat\) \}\}/,
/@click="gotoInstance\(instance\)"/,
]) {
assert.match(DISPLAY_PAGE, anchor)
}
})
@@ -0,0 +1,152 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { RealtimeClient } from '@/shared'
import { deviceLabel, type DeviceLabelSource } from '../deviceLabel'
import { DISPLAY_INSTANCE_STORAGE_KEY, resolveDisplayIdentity, type InstanceIdStorage } from '../instanceId'
/**
* 一条端到端的接线:局域网浏览器从「解析实例 id」到「真的发出 hello」。
*
* 修的就是这条链路:`setPage()` 翻页后会**再发一次 hello**(协议只有 hello 这一种上报方式),
* 而局域网浏览器原先 displayKey / instanceId 都是空的 —— 服务端 `registerDisplay` 在 id 为空时
* **每次现生成一个** `display-<随机>`,于是切一次页就在注册表里多留一条该设备的离线残影
* (管理端列表里凭空多出一条离线条目,TTL 十分钟)。
*
* 这里用真的 RealtimeClient + 真的 deviceLabel + 真的 instanceId 解析跑一遍:两条 hello 的
* instanceId 必须一模一样。纯逻辑测试测不出这件事(它们各自都对,接起来才出问题)。
*/
class FakeSocket {
static readonly CONNECTING = 0
static readonly OPEN = 1
static readonly CLOSED = 3
static instances: FakeSocket[] = []
readyState = FakeSocket.CONNECTING
onopen: (() => void) | null = null
onmessage: ((event: { data: unknown }) => void) | null = null
onerror: (() => void) | null = null
onclose: ((event: { code: number }) => void) | null = null
readonly sent: string[] = []
constructor() {
FakeSocket.instances.push(this)
}
send(data: string): void {
this.sent.push(data)
}
close(): void {
this.readyState = FakeSocket.CLOSED
this.onclose?.({ code: 1006 })
}
open(): void {
this.readyState = FakeSocket.OPEN
this.onopen?.()
}
hellos(): Array<Record<string, unknown>> {
return this.sent
.map((raw) => JSON.parse(raw) as { event: string; data: Record<string, unknown> })
.filter((message) => message.event === 'hello')
.map((message) => message.data)
}
}
function fakeStorage(initial: Record<string, string> = {}): InstanceIdStorage {
const map = new Map(Object.entries(initial))
return {
getItem: (key) => map.get(key) ?? null,
setItem: (key, value) => void map.set(key, value),
}
}
/** 安卓手机打开局域网地址:没有 instance / screen,只有 UA 与屏幕信息。 */
const ANDROID_SOURCE: DeviceLabelSource = {
userAgent:
'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36',
screen: { width: 1080, height: 2400 },
devicePixelRatio: 3,
}
function connectLanBrowser(
storage: InstanceIdStorage,
source: DeviceLabelSource,
): { socket: FakeSocket; client: RealtimeClient } {
;(globalThis as { WebSocket?: unknown }).WebSocket = FakeSocket
FakeSocket.instances = []
const identity = resolveDisplayIdentity({ instance: null, screen: null }, storage)
const label = deviceLabel(source)
const client = new RealtimeClient({
role: 'display',
url: 'ws://test/ws',
displayKey: identity.displayKey,
instanceId: identity.instanceId,
device: label.device,
deviceRaw: label.deviceRaw,
page: 'standings',
})
client.connect()
const socket = FakeSocket.instances[0]
assert.ok(socket)
socket.open()
return { socket, client }
}
test('局域网浏览器:切页补发的两条 hello 用同一个实例 id(不再多出离线残影)', () => {
const { socket, client } = connectLanBrowser(fakeStorage(), ANDROID_SOURCE)
client.setPage('team-medals')
const hellos = socket.hellos()
assert.equal(hellos.length, 2, '切页后必须补发一条 hello(服务端注册表里的当前页要跟上)')
assert.equal(typeof hellos[0]?.instanceId, 'string')
assert.match(String(hellos[0]?.instanceId), /^browser-[0-9a-f]{8}$/)
assert.equal(hellos[1]?.instanceId, hellos[0]?.instanceId, '两条 hello 的实例 id 必须一致')
assert.equal(hellos[1]?.displayKey, hellos[0]?.displayKey)
assert.equal(hellos[0]?.device, '安卓 10 · Chrome 120 · 1080×2400 @3x · 手机')
assert.equal(hellos[1]?.page, 'team-medals')
client.close()
})
test('刷新 / 重连(同一个浏览器)沿用 localStorage 里那一个实例 id', () => {
const storage = fakeStorage()
const first = connectLanBrowser(storage, ANDROID_SOURCE)
const firstName = first.socket.hellos()[0]?.instanceId
first.client.close()
// 重新打开页面:URL 还是没参数,但存储里有上一次那个 id。
const second = connectLanBrowser(storage, ANDROID_SOURCE)
assert.equal(second.socket.hellos()[0]?.instanceId, firstName)
second.client.close()
// 重新连接也仍然读同一个键。
assert.equal(typeof storage.getItem(DISPLAY_INSTANCE_STORAGE_KEY), 'string')
})
test('隐私模式(存储不可用):不报实例 id,行为退回今天的样子', () => {
const brokenStorage: InstanceIdStorage = {
getItem: () => {
throw new Error('SecurityError')
},
setItem: () => {
throw new Error('SecurityError')
},
}
const { socket, client } = connectLanBrowser(brokenStorage, ANDROID_SOURCE)
const hello = socket.hellos()[0]
assert.equal(hello?.instanceId, undefined, '存不下就不报,交给服务端生成')
assert.equal(hello?.displayKey, undefined)
// 标签照常有:存储问题不该影响设备信息。
assert.equal(hello?.device, '安卓 10 · Chrome 120 · 1080×2400 @3x · 手机')
client.close()
})
test('Console 打开的窗口:实例 id 与屏号仍然来自 URL,一个字不变', () => {
const identity = resolveDisplayIdentity({ instance: 'LAPTOP-1-2528732444', screen: '2' }, fakeStorage())
assert.equal(identity.displayKey, 'LAPTOP-1-2528732444')
assert.equal(identity.instanceId, 'LAPTOP-1-2528732444')
})
+134
View File
@@ -0,0 +1,134 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
DISPLAY_INSTANCE_ID_PREFIX,
DISPLAY_INSTANCE_STORAGE_KEY,
randomBrowserId,
resolveDisplayIdentity,
resolveInstanceId,
type InstanceIdStorage,
} from '../instanceId'
/**
* 局域网浏览器的实例 id 稳定化。
*
* 修的 bug:`RealtimeClient.setPage()` 翻页后会**再发一次 hello**,而局域网浏览器原先
* displayKey / instanceId 都是空,服务端每收到一次 hello 就现生成一个 `display-<随机>` ——
* 老连接那条留在注册表里变成「离线」条目(管理端列表凭空多出一条该设备的离线记录)。
* id 稳定下来之后,重发 hello 只是刷新同一条记录。
*/
/** 假的 localStorage:getItem / setItem 可以按需抛异常(隐私模式的两种表现)。 */
function fakeStorage(
initial: Record<string, string> = {},
options: { throwOnGet?: boolean; throwOnSet?: boolean } = {},
): InstanceIdStorage & { dump(): Record<string, string> } {
const map = new Map(Object.entries(initial))
return {
getItem(key: string): string | null {
if (options.throwOnGet) throw new Error('SecurityError: 存储被禁用')
return map.get(key) ?? null
},
setItem(key: string, value: string): void {
if (options.throwOnSet) throw new Error('QuotaExceededError')
map.set(key, value)
},
dump: () => Object.fromEntries(map),
}
}
function fixedRandom(value: string): () => string {
return () => value
}
test('URL 里带了实例 id / 屏号时原样用,且不碰存储', () => {
const consoleStorage = fakeStorage()
assert.equal(resolveInstanceId({ instance: 'LAPTOP-1-2528732444' }, consoleStorage), 'LAPTOP-1-2528732444')
// 只带屏号的窗口(Console 是这么开的)沿用 URL 的值,行为与升级前一致。
assert.equal(resolveInstanceId({ instance: null, screen: '2' }, consoleStorage), '2')
assert.deepEqual(consoleStorage.dump(), {}, 'Console 窗口不该往 localStorage 里写东西')
})
test('局域网浏览器(URL 没有 instance / screen):生成并写回本机稳定 id', () => {
const storage = fakeStorage()
const first = resolveInstanceId({}, storage, fixedRandom('3f2a1c9d'))
assert.equal(first, `${DISPLAY_INSTANCE_ID_PREFIX}3f2a1c9d`)
assert.equal(storage.dump()[DISPLAY_INSTANCE_STORAGE_KEY], first)
// 第二次调用(刷新、重连、切页补发 hello)必须是同一个:这里换一个随机源,证明它没有重新生成。
assert.equal(resolveInstanceId({}, storage, fixedRandom('ffffffff')), first)
})
test('存过的 id 直接用,连随机源都不用调', () => {
const storage = fakeStorage({ [DISPLAY_INSTANCE_STORAGE_KEY]: 'browser-abcdef01' })
const broken = (): string => {
throw new Error('不该在这里生成新 id')
}
assert.equal(resolveInstanceId({}, storage, broken), 'browser-abcdef01')
})
test('存储抛异常时返回 undefined,绝不让大屏连不上', () => {
// 读属性就抛的隐私模式在页面里的表现是 storage === null(见 instanceIdStorage)。
assert.equal(resolveInstanceId({}, null), undefined)
assert.equal(resolveInstanceId({}, fakeStorage({}, { throwOnGet: true })), undefined)
assert.equal(resolveInstanceId({}, fakeStorage({}, { throwOnSet: true })), undefined)
})
test('存下来的脏值不认:重新生成一个,而不是拿它去当实例 id', () => {
for (const bad of ['', ' ', 'x'.repeat(65), '{"id":"1"}', 'browser-😀']) {
const storage = fakeStorage({ [DISPLAY_INSTANCE_STORAGE_KEY]: bad })
assert.equal(
resolveInstanceId({}, storage, fixedRandom('00112233')),
`${DISPLAY_INSTANCE_ID_PREFIX}00112233`,
`脏值 ${JSON.stringify(bad)} 应被丢弃并重生成`,
)
}
})
test('空的 URL 参数(?instance=)按「没给」处理,落到本机稳定 id', () => {
const storage = fakeStorage()
// 以前 `?instance=` 会让 displayKey / instanceId 都变成空串,服务端照样现生成一个 —— 正是本文件要修的 bug。
assert.equal(resolveInstanceId({ instance: '', screen: '' }, storage, fixedRandom('aabbccdd')), 'browser-aabbccdd')
})
test('resolveDisplayIdentity:Console 窗口行为一个字不变,局域网浏览器两个字段都用稳定 id', () => {
const storage = fakeStorage()
// Console 窗口:displayKey 与 instanceId 都来自 URL 的 instance。
assert.deepEqual(resolveDisplayIdentity({ instance: 'LAPTOP-1-9' }, storage, fixedRandom('x')), {
displayKey: 'LAPTOP-1-9',
instanceId: 'LAPTOP-1-9',
browserId: undefined,
})
// 只带屏号:instanceId 仍然不上报(服务端按 displayKey 认它),与升级前完全一致。
assert.deepEqual(resolveDisplayIdentity({ screen: '2' }, storage, fixedRandom('x')), {
displayKey: '2',
instanceId: undefined,
browserId: undefined,
})
// 局域网浏览器:displayKey 与 instanceId 都是本机稳定 id,重发 hello 只会刷新同一条记录。
const lan = resolveDisplayIdentity({}, storage, fixedRandom('a1b2c3d4'))
assert.deepEqual(lan, {
displayKey: 'browser-a1b2c3d4',
instanceId: 'browser-a1b2c3d4',
browserId: 'browser-a1b2c3d4',
})
assert.deepEqual(resolveDisplayIdentity({}, storage, fixedRandom('ffffffff')), lan)
// 存储不可用:退回今天的行为(都为空,交给服务端生成),不能因此连不上。
assert.deepEqual(resolveDisplayIdentity({}, null), {
displayKey: undefined,
instanceId: undefined,
browserId: undefined,
})
})
test('浏览器生成器:格式是 browser-<8 位十六进制>', () => {
assert.match(randomBrowserId(), /^[0-9a-f]{8}$/)
assert.match(`${DISPLAY_INSTANCE_ID_PREFIX}${randomBrowserId()}`, /^browser-[0-9a-f]{8}$/)
})
+29 -2
View File
@@ -25,6 +25,8 @@ import { entryShareIndex, entrySquadIndex } from './jointTeams'
import type { DisplayTeamShare } from './jointTeams'
import { entryRosterIndex } from './members'
import { DEFAULT_ANNOUNCE_SECONDS, createAnnouncement, resolveAnnounceText } from './announce'
import { deviceLabel, deviceLabelSource, resolveDeviceLabel } from './deviceLabel'
import { instanceIdStorage, resolveDisplayIdentity } from './instanceId'
import { applyDisplayFonts } from './fonts'
import { liveRefreshDelay } from './live'
import { createMockAthletes, createMockSnapshot } from './mock'
@@ -686,13 +688,24 @@ watch(
function connect(): void {
if (client !== null) return
// 实例 id 必须在本机稳定:局域网浏览器(URL 没有 instance / screen)每次现生成一个的话,
// setPage 补发的那条 hello 会在服务端注册表里留下一条永远离线的老实例(TTL 十分钟),
// 管理端列表里就凭空多出「同一台设备的离线条目」——取舍与键名见 instanceId.ts。
const identity = resolveDisplayIdentity(
{ instance: options.value.instance, screen: options.value.screen },
instanceIdStorage(),
)
const label = deviceLabel(deviceLabelSource())
client = new RealtimeClient({
role: 'display',
displayKey: options.value.instance ?? options.value.screen ?? undefined,
instanceId: options.value.instance ?? undefined,
displayKey: identity.displayKey,
instanceId: identity.instanceId,
// Console 从 URL 带下来的屏号(screen=<显示器 id>),让管理端把本机显示器与在线实例对上;
// 局域网浏览器直接开 /display 时没有它,留空。
screen: options.value.screen ?? undefined,
// 设备标签:管理端列表拿它当主行(alias 优先),原始 UA 留给悬浮提示排障。
device: label.device,
deviceRaw: label.deviceRaw,
page: page.value,
initialVersion: version.value,
onSnapshot: (message) => {
@@ -742,6 +755,9 @@ async function bootstrap(): Promise<void> {
hydrateFromCache()
connect()
// 设备标签的高熵值(真实系统版本)只能异步拿,拿到了就补一条 hello;拿不到(内网 http 会被
// 直接拒绝)什么都不做,列表主行用同步那份标签。
void refineDeviceLabel()
const restOk = await loadRest()
if (!restOk && version.value === null) activateMock()
@@ -749,6 +765,17 @@ async function bootstrap(): Promise<void> {
if (!locked.value) startPageRotation()
}
/**
* 高熵值补齐设备标签(`getHighEntropyValues`)。
*
* 只在这一件事上有用:Chrome 把 Android 的 UA 归约成固定的 `Android 10`,真实系统版本只在
* 高熵的 `platformVersion` 里。拿不到就保持同步那份标签,`setDevice` 自己会判断有没有变化。
*/
async function refineDeviceLabel(): Promise<void> {
const refined = await resolveDeviceLabel(deviceLabelSource())
client?.setDevice(refined.device, refined.deviceRaw)
}
function dispose(): void {
stopPageRotation()
clearDetailTimer()
+45 -1
View File
@@ -435,12 +435,28 @@ export interface DisplayInstance {
slow: boolean
/** 屏号;局域网浏览器直接开 /display 时是空串。 */
screen: string
/**
* 设备标签(`安卓 13 · Chrome 120 · 1080×2400 · 手机`)。
*
* 老客户端 / 老服务端没有这个字段(omitempty),此时为 undefined——列表退回显示 id。
*/
device?: string
/** 原始 UA,悬浮提示排障用;同样可能缺字段。 */
deviceRaw?: string
/** 操作员手动设的名字(机器级,跟着实例 id 走);没设过时为 undefined。 */
alias?: string
}
export interface DisplayApi {
command(command: DisplayCommand): Promise<void>
/** 当前注册表里的全部实例,含离线(服务端按 TTL 保留一段时间,便于排查)。 */
instances(): Promise<DisplayInstance[]>
/**
* 给某个实例设置 / 清除别名(`PUT /api/display/instances/{id}/alias`,录入员及以上)。
*
* 空串(或只空白)表示清除,实例回到自动标签;返回该实例的最新记录(形状与列表元素一致)。
*/
setAlias(id: string, alias: string): Promise<DisplayInstance>
/** 「实时赛况」当前项目与其已发布成绩(判定由服务端按设置与时序完成)。 */
live(): Promise<LiveEvents>
}
@@ -722,7 +738,7 @@ function readDisplayInstance(value: unknown): DisplayInstance | null {
const id = readString(value, 'id')
if (id === null || id === '') return null
const ackVersion = value['ackVersion']
return {
const instance: DisplayInstance = {
id,
online: value['online'] === true,
page: readString(value, 'page') ?? '',
@@ -733,6 +749,23 @@ function readDisplayInstance(value: unknown): DisplayInstance | null {
slow: value['slow'] === true,
screen: readString(value, 'screen') ?? '',
}
// 这三个是后加的字段(老服务端不带):**缺字段时整个键不出现**,而不是补空串——
// 老服务端升级前那些用例是按「对象形状完全一致」钉住的,多一个空串键就会把它们判成回归。
// 只空白同理按「没有」处理:服务端把只空白当清除,界面这边也必须同口径。
const device = trimmed(readString(value, 'device'))
if (device !== null) instance.device = device
const deviceRaw = trimmed(readString(value, 'deviceRaw'))
if (deviceRaw !== null) instance.deviceRaw = deviceRaw
const alias = trimmed(readString(value, 'alias'))
if (alias !== null) instance.alias = alias
return instance
}
/** 空串 / 只空白都当成「没有这个值」;有内容时去掉首尾空白(粘贴来的名字几乎都带着)。 */
function trimmed(value: string | null): string | null {
if (value === null) return null
const text = value.trim()
return text === '' ? null : text
}
function readProjectInfo(value: unknown): ProjectInfo | null {
@@ -1010,6 +1043,17 @@ export class ApiClient {
}
return body.map(readDisplayInstance).filter((item): item is DisplayInstance => item !== null)
},
setAlias: async (id, alias) => {
const body = await http.request<unknown>(
`/display/instances/${encodeURIComponent(id)}/alias`,
{ method: 'PUT', body: { alias } },
)
const instance = readDisplayInstance(body)
if (instance === null) {
throw new ApiError('PARSE_FAILED', '大屏实例别名响应格式不符', 0, body)
}
return instance
},
live: () => http.request<LiveEvents>('/display/live'),
}
+38
View File
@@ -39,6 +39,15 @@ export interface RealtimeOptions {
instanceId?: string
/** 屏号(Console 从 URL 带下来的 screen),hello 时上报;局域网浏览器没有屏号,留空。 */
screen?: string
/**
* 设备标签(`安卓 13 · Chrome 120 · 1080×2400 · 手机`),管理端「在线大屏」列表的主行。
*
* 空串等同于不传:管理端会退回显示实例 id,老服务端也根本认不出这个字段。
* 高熵值(`getHighEntropyValues`)是异步的,拿到更准的版本后用 setDevice 再补一次。
*/
device?: string
/** 原始 UA,给管理端悬浮提示排障用;空串等同于不传。 */
deviceRaw?: string
/** 当前页名,hello 时上报;翻页后调用 setPage 再补一次。 */
page?: string
/** 大屏从 localStorage 恢复快照后传入,可让服务端只补增量。 */
@@ -93,6 +102,8 @@ export class RealtimeClient {
private readonly displayKey: string | undefined
private readonly instanceId: string | undefined
private readonly screen: string | undefined
private device: string | undefined
private deviceRaw: string | undefined
private readonly reconnect: Required<ReconnectOptions>
private readonly watchdogMs: number
private readonly handshakeMs: number
@@ -128,6 +139,8 @@ export class RealtimeClient {
this.displayKey = options.displayKey
this.instanceId = options.instanceId
this.screen = options.screen
this.device = emptyToUndefined(options.device)
this.deviceRaw = emptyToUndefined(options.deviceRaw)
this.currentPage = options.page
this.currentVersion = options.initialVersion ?? null
this.watchdogMs = options.watchdogMs ?? 25_000
@@ -225,6 +238,21 @@ export class RealtimeClient {
if (this.currentStatus === 'online') this.sendHello()
}
/**
* 设备标签补齐后再补一条 hello(高熵值 `getHighEntropyValues` 是异步的)。
*
* 与 setPage 同一套做法:协议只有 hello 这一种上报方式。重复补发不会再多出实例条目——
* 局域网浏览器的实例 id 已经在本机稳定下来了(见 display/instanceId.ts)。
*/
setDevice(device: string, deviceRaw: string): void {
const next = emptyToUndefined(device)
const nextRaw = emptyToUndefined(deviceRaw)
if (next === this.device && nextRaw === this.deviceRaw) return
this.device = next
this.deviceRaw = nextRaw
if (this.currentStatus === 'online') this.sendHello()
}
close(): void {
this.closedByUser = true
this.clearReconnectTimer()
@@ -382,6 +410,9 @@ export class RealtimeClient {
if (this.displayKey !== undefined) hello.displayKey = this.displayKey
if (this.instanceId !== undefined) hello.instanceId = this.instanceId
if (this.screen !== undefined) hello.screen = this.screen
// 设备标签可选:空串时不带这个字段,与老服务端(不认识它)保持同一份载荷形状。
if (this.device !== undefined) hello.device = this.device
if (this.deviceRaw !== undefined) hello.deviceRaw = this.deviceRaw
if (this.currentPage !== undefined) hello.page = this.currentPage
this.send('hello', hello)
}
@@ -474,3 +505,10 @@ export class RealtimeClient {
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
/** 空串 / 纯空白等同于「没给」:不带这个字段比带一个空串干净,老服务端也不会有多余判断。 */
function emptyToUndefined(value: string | undefined): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed === '' ? undefined : trimmed
}
+93
View File
@@ -100,6 +100,99 @@ test('hello 上报 page 与 instanceId,翻页后补发', () => {
client.close()
})
// 设备标签(管理端「在线大屏」列表的主行):有就带上,没有就不出现在载荷里。
test('hello 上报设备标签与原始 UA', () => {
installFakeWebSocket()
const client = new RealtimeClient({
role: 'display',
url: 'ws://test/ws',
instanceId: 'browser-a1b2c3d4',
device: '安卓 13 · Chrome 120 · 1080×2400 @3x · 手机',
deviceRaw: 'Mozilla/5.0 (Linux; Android 13) Chrome/120.0.0.0 Mobile Safari/537.36',
})
client.connect()
const socket = firstSocket()
socket.open()
assert.deepEqual(JSON.parse(socket.sent[0] ?? '{}'), {
event: 'hello',
data: {
role: 'display',
instanceId: 'browser-a1b2c3d4',
device: '安卓 13 · Chrome 120 · 1080×2400 @3x · 手机',
deviceRaw: 'Mozilla/5.0 (Linux; Android 13) Chrome/120.0.0.0 Mobile Safari/537.36',
},
})
client.close()
})
// 老服务端不认识这两个字段,空串也不该带上去:载荷形状与升级前完全一致。
test('没有设备标签 / 传空串时 hello 不带这两个字段', () => {
installFakeWebSocket()
const client = new RealtimeClient({
role: 'display',
url: 'ws://test/ws',
instanceId: 'browser-a1b2c3d4',
device: '',
deviceRaw: ' ',
})
client.connect()
const socket = firstSocket()
socket.open()
assert.deepEqual(JSON.parse(socket.sent[0] ?? '{}'), {
event: 'hello',
data: { role: 'display', instanceId: 'browser-a1b2c3d4' },
})
client.close()
})
// 高熵值(真实系统版本)是异步拿的,拿到之后用 setDevice 补一条 hello;没变化就不打扰服务端。
test('setDevice 补发 hello:值变化才发,清空后也不再带这两个字段', () => {
installFakeWebSocket()
const client = new RealtimeClient({
role: 'display',
url: 'ws://test/ws',
instanceId: 'browser-a1b2c3d4',
device: '安卓 10 · Chrome 120 · 手机',
})
client.connect()
const socket = firstSocket()
socket.open()
socket.sent.length = 0
client.setDevice('安卓 10 · Chrome 120 · 手机', '')
assert.equal(socket.sent.length, 0, '没变化不该补发')
client.setDevice('安卓 13 · Chrome 120 · 1080×2400 @3x · 手机', 'Mozilla/5.0')
const refined = JSON.parse(socket.sent[0] ?? '{}').data
assert.equal(refined.device, '安卓 13 · Chrome 120 · 1080×2400 @3x · 手机')
assert.equal(refined.deviceRaw, 'Mozilla/5.0')
socket.sent.length = 0
client.setDevice('', '')
const cleared = JSON.parse(socket.sent[0] ?? '{}').data
assert.equal('device' in cleared, false)
assert.equal('deviceRaw' in cleared, false)
client.close()
})
// 离线(还没握手完 / 已断开)时 setDevice 只记下来,等这条连接自己 hello 时带上。
test('未连上时 setDevice 不发消息,等下一次 hello 带上', () => {
installFakeWebSocket()
const client = new RealtimeClient({ role: 'display', url: 'ws://test/ws' })
client.connect()
const socket = firstSocket()
socket.sent.length = 0
client.setDevice('Windows 10 · Edge 120 · 1920×1080 · 桌面', 'Mozilla/5.0')
assert.equal(socket.sent.length, 0)
socket.open()
assert.equal(JSON.parse(socket.sent[0] ?? '{}').data.device, 'Windows 10 · Edge 120 · 1920×1080 · 桌面')
client.close()
})
// P1-17:策略性拒绝(1008)重连也会被同样拒绝,必须停重连并提示登录。
test('close 1008 停止重连并回调 onAuthRequired', async () => {
installFakeWebSocket()
+9
View File
@@ -1093,6 +1093,15 @@ export interface HelloMessage {
* 管理端用它把「本机显示器」与「在线实例」对上;局域网浏览器直接开 /display 时留空。
*/
screen?: string
/**
* 设备标签(`安卓 13 · Chrome 120 · 1080×2400 · 手机`),管理端「在线大屏」列表的主行。
*
* 浏览器自己算好报上来:服务端不解析 UA,只做长度截断与清洗;老客户端不带这个字段时
* 服务端行为与今天完全一致(列表退回显示 id)。
*/
device?: string
/** 原始 UA,管理端悬浮提示里给排障用;可为空。 */
deviceRaw?: string
}
export interface AckMessage {