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:
1 parent
ee19b92182
commit
56ed90ff8b
53 files changed
+5675
-221
No files matched your search
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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)} 分来自上一次按决赛发布):发布时选「只更新名次」再发一次只会更新名次,这些积分与奖牌不会被改动。`
|
||||
}
|
||||
@@ -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' : ''
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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}`,
|
||||
)
|
||||
})
|
||||
@@ -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">/)
|
||||
})
|
||||
@@ -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(含非安全上下文回退),失败有提示', () => {
|
||||
|
||||
@@ -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\]/)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 起是稳定的):服务端按发布时间给出的并列行
|
||||
* 不许在这里被换位置,否则前两栏的观感就变了。无名次(延迟结算)的行排最后。
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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}$/)
|
||||
})
|
||||
@@ -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
@@ -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'),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user