## 算分正确性(6 份只读审查 → 收敛成 5 个病根,均先复现失败再修) - 「人工名次」与「自动名次回写」互斥:原来同一份成绩**发布两次分数不同** (团体报名项 dual 通道 10/8/6 → 25/18/14),现在连发多次逐字节一致 - 删小项时在同一事务作废旧流水并推榜:原来分数**永久残留**在总榜、任何重算都打不到 - 规则包导入整体事务化:原来中途失败会清空名次表且补不回(奖牌全丢); 另加守卫「compare=none 的小项不得配 basis=result/aggregate」 - 末位归零只抹真正落在末位的那一档:原来跨末位的并列组会被整组抹 0 (7 人三人并列第 5 时第 5、6 名的分也一起没了) - 模板「分值表档位超出录取名次」现在拒绝保存:原来静默截断成 0 分 ## 现场反馈(主人实测报的) - 已发布成绩可改:行内「修改本条」+ 成绩格/状态格可点(退回草稿→改→重新发布本组), 与「作废」语义分开;取消路径不产生任何副作用 - 单位积分明细补「运动员 / 来源」列,并显示「已修订 ×N(时间·原因)」: 管理端、大屏覆盖层、xlsx 备注列三处同源 - 单位奖牌榜改为按奖牌独立排名(金→银→铜→单位编码),与积分榜解耦 - 历史孤儿积分流水清理(迁移 0022,只动 result/record_bonus,人工调整分不受影响) ## 新功能 - 团体项目聚合口径二选一:平均(默认)/ 总和(迁移 0023,通道编辑器可切) ## 安装器 - 自绘五页安装界面:无边框、自绘标题栏、DPI 自适应(4K@200% 实测)、 安装/卸载逻辑复用 electron-builder 宏不变;默认「为所有用户安装」并自动放行防火墙 19540 - 构建期校验:安装器素材里的版本号必须与 VERSION 一致,否则构建失败 自检:服务端 14 包 ×2 / 前端 vue-tsc + build + 742 例 ×2 / Console 20 文件 + 68 例 ×2,全绿。 端到端对拍:修前 vs 修后,纯团体 / 双计 / 普通个人三形态**逐字节一致**,仅出 bug 的双通道形态按要求改变。
232 lines
14 KiB
JavaScript
232 lines
14 KiB
JavaScript
// 构建前准备三样东西:
|
||
// 1) 把 docs/ 下的使用说明与报名名单导入规范暂存到 console/extra/,供 electron-builder 的 extraFiles 平铺到部署包根目录;
|
||
// 2) 生成安装器自定义脚本(console/resources/installer-custom.nsh):许可页顶部文案(署名来自构建机本地文件)
|
||
// + 记住上次安装目录(重装时沿用,而不是退回系统默认路径)
|
||
// + 按安装模式放行/回收防火墙入站 19540(「为所有用户安装」那一趟本来就提了权,顺手做掉,
|
||
// 省得现场再拿管理员 cmd 手工敲 netsh);
|
||
// 3) 读仓库根 VERSION 文件:生成 deploy/version.gen.bat(供 build.bat 注入 Go 二进制)
|
||
// 并同步 console/package.json 的 version(决定安装包/免安装包的文件名)。
|
||
// 用 Node 而不是批处理:中文文件名/中文内容在 .bat 里会被 cmd 解析错乱。
|
||
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||
import { dirname, join } from 'node:path'
|
||
|
||
const root = dirname(import.meta.dirname)
|
||
|
||
// ---- 1. usage docs ----------------------------------------------------------
|
||
// 都暂存成 .txt:部署包根目录里给现场双击打开,.md 在记事本里全是符号。
|
||
// 源文件优先取 docs/ 下同名 .txt(便于临时替换),否则用仓库里的 .md。
|
||
const extraDir = join(root, 'console', 'extra')
|
||
mkdirSync(extraDir, { recursive: true })
|
||
const docsToStage = [
|
||
{ target: '使用说明.txt', sources: ['使用说明.txt', '使用手册.md'], label: 'usage doc' },
|
||
{ target: '报名名单导入规范.txt', sources: ['报名名单导入规范.md'], label: 'entry import spec' },
|
||
// 升级须知也要随包走:现场覆盖安装前能在安装目录里先看到「这一版改了什么、要补什么」。
|
||
{ target: '升级说明.txt', sources: ['升级说明.md'], label: 'upgrade notes' },
|
||
]
|
||
for (const doc of docsToStage) {
|
||
const source = doc.sources.map((name) => join(root, 'docs', name)).find((file) => existsSync(file))
|
||
if (!source) {
|
||
console.log(` [docs] no ${doc.label}, skip`)
|
||
continue
|
||
}
|
||
copyFileSync(source, join(extraDir, doc.target))
|
||
console.log(` ${doc.label} staged from ${source}`)
|
||
}
|
||
|
||
// ---- 1b. 自绘安装器脚本的编码检查 -------------------------------------------
|
||
// 本机手工跑 makensis 时必须带 UTF-8 BOM,否则脚本里的中文直接被判成 Bad text encoding;
|
||
// electron-builder 那边传了 -INPUTCHARSET UTF8,构建机上没 BOM 也能过 —— 两边口径不一致,
|
||
// 出问题时只会表现为「本地编不过、构建机编得过」这种最难查的差异,所以统一要求带 BOM。
|
||
const installerScript = join(root, 'console', 'installer', 'installer.nsi')
|
||
if (existsSync(installerScript)) {
|
||
const head = readFileSync(installerScript).subarray(0, 3)
|
||
if (!(head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf)) {
|
||
console.error(` [installer] ${installerScript} 缺少 UTF-8 BOM:本机 makensis 会报 Bad text encoding`)
|
||
console.error(' [installer] 修复:用编辑器另存为「UTF-8 with BOM」,或跑 python3 -c "…" 补 BOM')
|
||
process.exit(1)
|
||
}
|
||
console.log(' installer script has UTF-8 BOM')
|
||
}
|
||
|
||
// ---- 2. installer custom script ---------------------------------------------
|
||
// electron-builder 的 nsis.include 只收一个文件,所以许可页文案、安装目录记忆与防火墙规则写在同一个 .nsh 里。
|
||
const signatureFile = join(root, 'deploy', 'signature.local.txt')
|
||
const nshDir = join(root, 'console', 'resources')
|
||
const nshFile = join(nshDir, 'installer-custom.nsh')
|
||
const legacyNshFile = join(nshDir, 'installer-signature.nsh')
|
||
mkdirSync(nshDir, { recursive: true })
|
||
|
||
let topText = '请阅读并接受许可协议'
|
||
if (existsSync(signatureFile)) {
|
||
const signature = readFileSync(signatureFile, 'utf8').trim()
|
||
if (signature !== '') topText = `内部署名:${signature}`
|
||
}
|
||
|
||
// 换行用 CRLF:.gitattributes 里 *.nsh 就是 CRLF,NSIS 在 Windows 上按CRLF 读最稳。
|
||
const nsh = [
|
||
'!include "LogicLib.nsh"',
|
||
'',
|
||
'; 安装器自定义脚本:由 deploy/prepare-extra.mjs 生成,不要手改。',
|
||
'; 内容:许可页文案、上次安装目录记忆、防火墙入站规则(装时放行 / 卸时回收)、卸载钩子。',
|
||
'',
|
||
'; 自己记的安装目录(per-user 落 HKCU,per-all-users 落 HKLM)。',
|
||
'; electron-builder 自带的 ${INSTALL_REGISTRY_KEY} 在卸载时会被整键删掉,',
|
||
'; 「先卸载再装新版」或换过安装模式就会退回系统默认路径,所以另存一份不会被卸载器清理的。',
|
||
'!define SCOREBOARD_INSTALL_KEY "Software\\Scoreboard Console"',
|
||
'',
|
||
'; 防火墙入站规则的规则名:安装与卸载共用这一个字面量,改它两处一起改(名字对不上就删不掉)。',
|
||
'; 端口写死安装包的默认端口:现场改过 config.yaml 里的 server.port 时,这条规则对不上实际端口,',
|
||
'; 得按实际端口现场再放行一次(见 docs/部署指南.md 7.5)。',
|
||
'!define SCOREBOARD_FIREWALL_RULE "Scoreboard Console (TCP 19540)"',
|
||
'!define SCOREBOARD_FIREWALL_PORT "19540"',
|
||
'',
|
||
'!macro customHeader',
|
||
` !define MUI_LICENSEPAGE_TEXT_TOP "${topText}"`,
|
||
'!macroend',
|
||
'',
|
||
'!macro customInit',
|
||
' ; 在 .onInit 里、initMultiUser 之后执行:此时改 $INSTDIR 还来得及影响后面的目录页。',
|
||
' ReadRegStr $R9 HKCU "${SCOREBOARD_INSTALL_KEY}" "InstallLocation"',
|
||
' ${If} $R9 != ""',
|
||
' StrCpy $INSTDIR $R9',
|
||
' ; 安装模式页(per-user / per-all-users)离开时,electron-builder 会用自己那个键',
|
||
' ; 重新算一遍 $INSTDIR;卸载后它已经被删了,把记忆值补写回同一个 hive 里。',
|
||
' ; 不跨 hive 写:否则会把 per-machine 装的机器悄悄带成默认 per-user 模式(反之亦然)。',
|
||
' WriteRegStr HKCU "${INSTALL_REGISTRY_KEY}" "InstallLocation" "$R9"',
|
||
' ${Else}',
|
||
' ReadRegStr $R9 HKLM "${SCOREBOARD_INSTALL_KEY}" "InstallLocation"',
|
||
' ${If} $R9 != ""',
|
||
' StrCpy $INSTDIR $R9',
|
||
' WriteRegStr HKLM "${INSTALL_REGISTRY_KEY}" "InstallLocation" "$R9"',
|
||
' ${Else}',
|
||
' ; 没有自己的记忆时,退回 electron-builder 的键(老版本装过、没卸载过的情况)',
|
||
' ReadRegStr $R9 HKCU "${INSTALL_REGISTRY_KEY}" "InstallLocation"',
|
||
' ${If} $R9 == ""',
|
||
' ReadRegStr $R9 HKLM "${INSTALL_REGISTRY_KEY}" "InstallLocation"',
|
||
' ${EndIf}',
|
||
' ; 只认非空值;旧目录已经被删掉也照样用(这就是「记住我的路径」的语义)。',
|
||
' ${If} $R9 != ""',
|
||
' StrCpy $INSTDIR $R9',
|
||
' ${EndIf}',
|
||
' ${EndIf}',
|
||
' ${EndIf}',
|
||
' ClearErrors',
|
||
'!macroend',
|
||
'',
|
||
'!macro customInstall',
|
||
' ; SHELL_CONTEXT 跟随安装模式:per-user → HKCU,per-all-users → HKLM。',
|
||
' WriteRegStr SHELL_CONTEXT "${SCOREBOARD_INSTALL_KEY}" "InstallLocation" "$INSTDIR"',
|
||
'',
|
||
' ; 放行防火墙入站端口,只在「为所有用户安装」这一路做。依据:',
|
||
' ; $installMode 由 electron-builder 的安装模式页写定(multiUser.nsh 里就是 "all" / "CurrentUser");',
|
||
' ; 选「为所有用户安装」时安装器先弹 UAC 提权、提权成功的那个进程才继续跑安装段',
|
||
' ; (multiUserUi.nsh 里对「all users」单选钮的处理:UAC_RunElevated,外层进程直接 Quit),',
|
||
' ; 所以走到这里时进程已经是管理员,netsh 才有权限改防火墙。',
|
||
' ; 选「仅为我安装」全程不提权(installer.nsi 里 RequestExecutionLevel user),netsh 必然失败,',
|
||
' ; 所以这里连试都不试:省一次注定失败的调用,也不往安装日志里塞看不懂的报错。',
|
||
' ; UAC_IsAdmin 再确认一次当前确实是提权进程(宏来自 UAC.nsh,multiUser.nsh 已经 include 过),',
|
||
' ; 两个条件都成立才动手,任何情况下都不会拿普通用户权限去调 netsh。',
|
||
' ${If} $installMode == "all"',
|
||
' ${AndIf} ${UAC_IsAdmin}',
|
||
' ; 先删后加:覆盖安装/重装时把上一条同名规则清掉,免得同名规则越积越多。',
|
||
' ; ExecToLog = 隐藏执行、不弹黑窗,输出只进安装日志(装完展开「显示细节」才看得到)。',
|
||
' nsExec::ExecToLog \'netsh advfirewall firewall delete rule name="${SCOREBOARD_FIREWALL_RULE}"\'',
|
||
' Pop $0',
|
||
' ; 失败一律忽略:netsh 返回非 0(被组策略禁用、杀软拦截、防火墙服务被拆……)就只留一行日志,',
|
||
' ; 绝不能因为防火墙这一步让安装失败——规则没加上,现场还能按 docs/部署指南.md 7.5 手工补。',
|
||
' nsExec::ExecToLog \'netsh advfirewall firewall add rule name="${SCOREBOARD_FIREWALL_RULE}" dir=in action=allow protocol=TCP localport=${SCOREBOARD_FIREWALL_PORT}\'',
|
||
' Pop $0',
|
||
' ClearErrors',
|
||
' ${EndIf}',
|
||
'!macroend',
|
||
'',
|
||
'!macro customUnInstall',
|
||
' ; 安装目录记忆故意不删:需求就是「卸载后重装仍沿用上次的目录」。',
|
||
' ; 若改成卸载即遗忘,在这里补两行(卸载时 SHELL_CONTEXT 不一定已设定,要写死根键):',
|
||
' ; DeleteRegValue HKCU "${SCOREBOARD_INSTALL_KEY}" "InstallLocation"',
|
||
' ; DeleteRegValue HKLM "${SCOREBOARD_INSTALL_KEY}" "InstallLocation"',
|
||
'',
|
||
' ; 回收安装时加的那条入站规则。卸载走哪条路要看当初是怎么装的:',
|
||
' ; 按「为所有用户安装」装的:卸载器进卸载段之前会先 UAC 提权',
|
||
' ; (multiUserUi.nsh 里发现只有 per-machine 安装、且当前不是管理员时 UAC_RunElevated),',
|
||
' ; 当前进程是管理员,netsh 能删掉;',
|
||
' ; 按「仅为我安装」装的:全程不提权,netsh 必然失败——需求是「失败就算了」。',
|
||
' ; 所以这里只判断「是不是管理员」,不判断安装模式:非管理员时连试都不试,不报错、不留提示,',
|
||
' ; 免得卸载日志里多出一行用户看不懂的 netsh 报错。',
|
||
' ${If} ${UAC_IsAdmin}',
|
||
' nsExec::ExecToLog \'netsh advfirewall firewall delete rule name="${SCOREBOARD_FIREWALL_RULE}"\'',
|
||
' Pop $0',
|
||
' ClearErrors',
|
||
' ${EndIf}',
|
||
'!macroend',
|
||
'',
|
||
].join('\r\n')
|
||
|
||
writeFileSync(nshFile, nsh, 'utf8')
|
||
// 改名前生成的旧脚本已经没人引用,构建机上顺手清掉,免得看起来还在用。
|
||
rmSync(legacyNshFile, { force: true })
|
||
console.log(` installer custom script ready (${existsSync(signatureFile) ? 'with' : 'without'} internal signature)`)
|
||
|
||
// ---- 3. version -------------------------------------------------------------
|
||
// 版本号只维护仓库根的 VERSION 文件(唯一真相):这里读出来分两路用——
|
||
// deploy/version.gen.bat 给 build.bat,编译期用 -X main.version 写进二进制;
|
||
// console/package.json electron-builder 据此给安装包/免安装包命名。
|
||
// 二进制里的版本在编译那一刻定死,运行期不再读任何文件。
|
||
const versionFile = join(root, 'VERSION')
|
||
if (!existsSync(versionFile)) {
|
||
console.error(' [version] missing VERSION file at repo root')
|
||
process.exit(1)
|
||
}
|
||
const version = (readFileSync(versionFile, 'utf8')
|
||
.replace(/^\uFEFF/, '')
|
||
.split(/\r?\n/)
|
||
.find((line) => line.trim() !== '') ?? '').trim()
|
||
if (version === '') {
|
||
console.error(' [version] VERSION file is empty')
|
||
process.exit(1)
|
||
}
|
||
|
||
// ---- 3b. 安装器素材的版本号核对 ---------------------------------------------
|
||
// 安装界面的版本号是「烘焙进位图」的(console/installer/art/*/brand.bmp 的品牌栏),
|
||
// 位图里读不出文本,所以 gen_art.py 另外写了一份 art/version.txt 作旁证。
|
||
// 不核对的话会出现「安装界面写着 v0.5.1、装的其实是 0.5.2」——这种错只在装完之后
|
||
// 被人一眼看到,而且很容易随包发出去;宁可构建直接失败,也不要发错版本号。
|
||
const artDir = join(root, 'console', 'installer', 'art')
|
||
if (existsSync(artDir)) {
|
||
const artVersionFile = join(artDir, 'version.txt')
|
||
const artVersion = existsSync(artVersionFile) ? readFileSync(artVersionFile, 'utf8').trim() : ''
|
||
const wantVersion = version.replace(/^dev_/, '')
|
||
if (artVersion !== wantVersion) {
|
||
console.error(` [art] 安装器素材里的版本号与 VERSION 对不上:素材=${artVersion || '(缺失)'},VERSION=${wantVersion}`)
|
||
console.error(' [art] 修复:跑 python3 console/installer/art-gen/gen_art.py 重新生成素材,然后提交')
|
||
process.exit(1)
|
||
}
|
||
console.log(` installer art version matches (${wantVersion})`)
|
||
}
|
||
|
||
// 必须纯 ASCII:cmd.exe 会错解 .bat 里的非 ASCII 文本(见本文件头部说明)。
|
||
// 用 CRLF:与 .gitattributes 里 *.bat 的约定一致,cmd 在 Windows 上按 CRLF 读最稳。
|
||
// 只 set 不加 setlocal:build.bat 用 call 调它,变量要留在调用方的环境里。
|
||
const genBatFile = join(root, 'deploy', 'version.gen.bat')
|
||
const genBat = [
|
||
'@echo off',
|
||
'REM Generated by deploy/prepare-extra.mjs from the VERSION file - do not edit.',
|
||
`set "SCOREBOARD_VERSION=${version}"`,
|
||
'',
|
||
].join('\r\n')
|
||
writeFileSync(genBatFile, genBat, 'utf8')
|
||
console.log(` version ${version} -> deploy/version.gen.bat`)
|
||
|
||
// console/package.json 的 version 是 semver,不带 dev_ 前缀(dev_ 只用于二进制版本号)。
|
||
// 逐字替换而不是 JSON.parse/stringify:保住原有缩进与末尾换行,改动最小。
|
||
const pkgVersion = version.replace(/^dev_/, '')
|
||
const pkgFile = join(root, 'console', 'package.json')
|
||
const pkgRaw = readFileSync(pkgFile, 'utf8')
|
||
const pkgNext = pkgRaw.replace(/^(\s*"version"\s*:\s*)"[^"]*"/m, `$1"${pkgVersion}"`)
|
||
if (pkgNext === pkgRaw) {
|
||
console.log(` console/package.json version is ${pkgVersion}`)
|
||
} else {
|
||
writeFileSync(pkgFile, pkgNext, 'utf8')
|
||
console.log(` console/package.json version -> ${pkgVersion}`)
|
||
}
|