diff --git a/README.md b/README.md index 2f186e1..9920ae4 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,12 @@ - **现场公开**:积分明细可下钻,争议时一键推送到大屏 - **全屏公告**:推送后先全屏显示(5–120 秒),随即常驻大屏底部公告栏,直到替换或清除 - **小项类型齐全**:个人 / 团体 / 对抗比赛 / **个人+团体**(同一个项目同时按个人与团体通道记分) -- **多显示器**:支持多屏各锁一页(kiosk 全屏),也能在管理端为指定显示器单独开窗或收窗 +- **多显示器**:支持多屏各锁一页(kiosk 全屏),也能在管理端为指定显示器单独开窗或收窗;「大屏控制」页的「在线大屏」列表实时显示每个在线实例(含局域网浏览器打开的 `/display`)当前在哪一页,可逐屏 / 逐设备单独切页 - **规则可配置**:积分规则以规则包(YAML)描述,可导入导出、跨赛事复用 - **运动员名册**:启用 / 停用 / **彻底删除**三档——「停用」保留全部历史数据、随时可启用;「彻底删除」把人员档案连同他名下的报名、成绩与积分流水一起删掉且不可恢复,并自动按新排序重算受影响小项的排名与积分(大屏同步更新) - **参赛单位名册**:启用 / 停用 / **彻底删除**三档——「停用」保留全部历史数据、随时可启用;「彻底删除」是**假删除**,只把单位从列表里移除(界面上没有恢复入口),它的报名、成绩、积分流水与名下运动员全部保留,**积分榜不会变化**;报名表里再次用到该单位编码会让它自动恢复 - **赛事独立存档**:每个赛事一个独立项目目录,整包可导出/导入(含会标、背景图、单位 logo 与上传的字体),并可在管理端页内一键切换当前赛事 -- **结果导出**:Excel 总表、全部分项、分项成绩表、单位积分明细可分别导出 +- **结果导出**:Excel 总表、全部(多 sheet)、**全部(单页)**、分项成绩表、单位积分明细可分别导出;「积分榜」页还可导出**单位积分总明细**(总表 + 每个有数据的单位一张分表)与**单个单位的积分明细** - **单位 logo**:积分榜、奖牌榜、分项成绩表与个人榜都会显示参赛单位 logo - **大屏字体可配**:标题字体与正文字体可在「系统设置 → 赛事信息 → 字体」里从四款内置字体中选,或上传自己的字体(ttf / otf / woff / woff2,单个不超过 20 MiB);只作用于大屏,加载失败自动回落默认字体,字体文件随赛事项目包一起导出/导入 diff --git a/docs/上线检查清单.md b/docs/上线检查清单.md index a7d80f8..6dd8aff 100644 --- a/docs/上线检查清单.md +++ b/docs/上线检查清单.md @@ -51,6 +51,7 @@ - [ ] 只有一块屏时是窗口化模式,按 **F11** 可切全屏(用于单屏测试) - [ ] **调试标识已隐藏**:多屏 kiosk 全屏画面上不该出现「实例 … · 屏 …」;窗口化窗口(单屏)里显示这一行属正常,按 F11 全屏后它与自绘标题栏一起消失(版本号角标已移除,任何画面都不该出现) - [ ] **显示器菜单可用**:在服务管理窗口里点「打开服务端管理面板」,进「大屏控制 → 显示器」,能看到每块屏;副屏点「创建全屏窗口」能重新开出来(单屏 / 主显示器只能窗口化,属正常) +- [ ] **在线大屏与逐屏切页**:同页右侧「在线大屏」列表能看到本机每块屏的在线实例(局域网设备也已在这一列表里,标记为「局域网浏览器」),每行显示当前所在页;用某一行自己的下拉 +「切到此页」能把那一块屏 / 那台设备单独切走,其余屏不受影响(离线实例的控件为灰色,属正常) - [ ] **公告可用**:推送一条测试公告 → 大屏全屏显示 → 到时退回原页且**底栏公告栏仍常驻显示正文** → 点「清除公告」后底栏回到「暂无公告」 ## 6. 数据与备份 @@ -58,11 +59,13 @@ - [ ] 确认数据目录 `%LOCALAPPDATA%\ScoreboardConsole\data` 下的 `backups\` 里已出现 `system-auto-*.db` 与 `event-auto-*.db`(默认每 5 分钟) - [ ] 开幕前手动点一次「立即备份」(「系统设置 → 备份 → 立即备份」) - [ ] 把整个数据目录(不只是 `backups\`:`event_projs\<当前项目名>\uploads\` 里还有会标、背景图、单位 logo 与上传的字体)拷一份到 U 盘 +- [ ] 留一份成绩总表存档:到「导出 → 结果导出」导一份**全部(单页)**(`赛事成绩总表(单页).xlsx`,一个工作簿一张表,适合比对与筛选),需要归档打印再导一份「全部」(多 sheet) ## 7. 现场流程演练(建议至少一次) - [ ] 录入员登录 → 录入一条成绩 → 存草稿 → 发布 → 大屏刷新 - [ ] 撤销发布 → 修改 → 重新发布(历史记录里能看到链路) +- [ ] 导出核对:「结果导出」里「全部」与「全部(单页)」都能正常下载(后者应是一个工作簿、只有一张表);「积分榜」页点「导出总明细」得到 `单位积分总明细.xlsx`,展开某单位后点「导出该单位明细」得到 `单位积分明细.xlsx` - [ ] 断网 → 大屏仍显示缓存数据 → 恢复后自动补增量 - [ ] 重启服务端 → Console 自动拉起 diff --git a/docs/使用手册.md b/docs/使用手册.md index f5788fb..084e099 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -201,9 +201,17 @@ ### 8.2 团体奖牌榜 / 个人积分榜 - 团体奖牌榜显示金、银、铜与合计; -- 个人积分榜显示运动员、单位、个人总分与奖牌数。 +- 个人积分榜显示运动员、单位、个人总分与奖牌数; +- 团体奖牌榜那一列的「明细」展开的是**同一个单位积分明细面板**(见 8.1),不是奖牌专属明细;个人积分榜**没有明细列**。 -### 8.3 推送明细到大屏 +### 8.3 导出明细(xlsx) + +- **导出总明细**:页头右上角的按钮,导出 `单位积分总明细.xlsx`——一张带「单位」列的总表,外加每个**有数据**的单位一张分表;没有积分的单位不出分表; +- **导出该单位明细**:右侧单位明细面板头部的按钮,先展开某个单位再点,导出 `单位积分明细.xlsx`(列:序号、大项、小项、组别、运动员、名次、成绩、来源、积分、备注,末行是「合计」); +- 团体奖牌榜展开的也是这个单位积分明细面板,所以同样用「导出该单位明细」导出;个人积分榜没有明细列,也就没有对应的导出; +- 两个导出都是只读下载(访客也能用),不需要写权限;导出过程中按钮会变成「导出中…」并禁用,避免重复下载。 + +### 8.4 推送明细到大屏 1. 展开某个单位的明细; 2. 点「推送到大屏」; @@ -216,7 +224,7 @@ | 区块 | 操作 | | --- | --- | -| 切页 | 选择「总积分榜 / 团体奖牌榜 / 个人积分榜 / 最新成绩」,点「立即跳转」 | +| 切页 | 选择「总积分榜 / 团体奖牌榜 / 个人积分奖牌榜 / 分项成绩 / 公告」,点「立即跳转」(**所有大屏一起切**);只想切某一块屏或某台设备,用右侧「在线大屏」卡片里那一行的下拉与「切到此页」 | | 锁定与刷新 | **锁定**:停止自动轮播并停在当前页;**解锁**:恢复轮播;**强制刷新**:让所有大屏重新拉取快照,用于排障 | | 公告 | 输入公告文字,设置「滚动时长」(5–120 秒,实际是全屏展示的秒数),点「推送公告」;点「清除公告」撤下当前公告 | @@ -224,7 +232,9 @@ **公告**:点「推送公告」后所有大屏先**全屏**展示这条公告,到时间后自动退回原来的页面(没锁定就继续轮播),公告正文留在底栏的「公告」槽位继续显示,直到被新公告替换或点「清除公告」。公告卡片顶部徽标显示「当前公告:有 / 无」,徽标下面那一行就是当前公告正文(没有公告时显示「暂无公告(大屏底栏显示占位)」)。公告会随赛事设置保存:新开的大屏窗口、断线重连之后,会先把当前公告再全屏播一遍(默认 15 秒)后留在底栏;点「清除公告」后底栏回到「暂无公告」占位。 -**在线看板**:本页这块在线状态是**本机占位显示**(管理端网页没有接服务端的实例接口),因此恒显示「未知」,「最近下发」只是本机下发指令的时间,**不代表大屏在线**。要看真正在线的实例,用**服务管理窗口**的「已连接的大屏实例」卡片——它读服务端的 `GET /api/display/instances`,每 5 秒刷新一次,列出在线实例数与各自页面(需先以记录员及以上身份在管理面板登录过)。 +**在线大屏**:右侧「在线大屏」卡片列出的是**真实**的在线实例,直接读服务端 `GET /api/display/instances`,打开页面时每 5 秒自动刷新一次(浏览器切到后台时暂停,切回来立刻补一次)。每个实例一行,显示实例 ID、在线 / 离线徽标、「局域网浏览器」或「屏 N」标记、当前所在页、来源地址与最后心跳;服务端判定某个实例落后时还会多一个「落后」徽标(大屏可能在卡或网络差)。**本机用 Console 打开的大屏窗口、以及局域网设备打开 `/display` 连上的浏览器,都会出现在这里**——局域网浏览器没有屏号,那一栏显示「局域网浏览器」。离线实例会在服务端保留一段时间,方便排查它是什么时候掉的。 + +**逐屏 / 逐设备切页**:在线大屏列表里每一行都在「当前页」旁带一个页面下拉和「切到此页」按钮,只把**这一块屏 / 这一台设备**切过去,其他屏不受影响;上方「切页」卡片里的「立即跳转」则是**所有大屏一起切**。离线实例的下拉与按钮是灰的,发不到它;点名的大屏一台都没收到指令时会明确提示「目标大屏已离线,请刷新在线列表」,不会谎报成功。列表读取失败时页面会提示「读取在线大屏失败」,并保留上一次的结果,不会把列表清空。 ### 9.1 显示器菜单(重建被关掉的大屏窗口) @@ -233,8 +243,9 @@ 这张「显示器」卡片只在**从服务管理窗口点「打开服务端管理面板」打开的面板**里可用;用浏览器直接打开管理面板时,卡片只显示一行提示,这是正常的。 1. **窗口页面**:先选好新窗口锁定显示哪一页(默认「总积分榜」); -2. **显示器列表**:逐块屏列出名称、分辨率、缩放、是否主显示器,以及当前是否已开窗口(「已开窗口(全屏)」「已开窗口(窗口化)」或「未开窗口」); +2. **显示器列表**:逐块屏列出名称、分辨率、缩放、是否主显示器、当前是否已开窗口(「已开窗口(全屏)」「已开窗口(窗口化)」或「未开窗口」),以及**该屏窗口当前在哪一页**(没开窗口时显示「窗口未开」;窗口开着但还没上报时显示「未上报」); 3. **每行的按钮**: + - 该屏的窗口已连上服务端(列表里能找到对应的在线实例)→ 页面下拉 + **切到此页**:只把这一块屏切过去,不影响别的屏;找不到对应在线实例时这两个控件是灰的,行内会注明原因(「该屏窗口已关闭(实例离线),先开窗或等它重连」或「该屏窗口未开,或大屏没上报屏号」); - 该屏已经有窗口 → **聚焦窗口**:把窗口提到最前,不重建、不打断正在展示的比分; - 该屏还没有窗口,且允许多屏全屏 → **创建全屏窗口**; - 该屏还没有窗口,但不允许全屏 → **创建窗口(窗口化)**,行内会注明「单显示器 / 主显示器只能窗口化」; @@ -446,10 +457,12 @@ | --- | --- | --- | | 总表 | 总积分榜 + 团体奖牌榜 + 个人积分奖牌榜,一个工作簿 | `总积分榜.xlsx` | | 全部 | 总表 + 全部分项,多 sheet 工作簿,适合归档打印 | `赛事成绩总表.xlsx` | +| 全部(单页) | 所有单位、所有项目、所有成绩**平铺在一张表**里(一个工作簿只有一张表),适合筛选与二次统计 | `赛事成绩总表(单页).xlsx` | | 分项成绩表 | 选择一个小项导出成绩与积分 | `分项成绩表.xlsx` | | 单位积分明细 | 选择一个单位,逐项列出得分来源 | `单位积分明细.xlsx` | - 「分项成绩表」需先选择小项,「单位积分明细」需先选择单位; +- **「全部」与「全部(单页)」的区别**:两者内容同源,覆盖的都是全部小项的全部报名 / 成绩;「全部」把每个小项放成一张分表(多 sheet 工作簿),「全部(单页)」把所有行平铺进同一张表,每行前面补「大项 / 小项 / 组别」三列以便脱离表名也能看懂。单页表的列是:大项、小项、组别、名次、号码、单位、运动员/队、成绩、积分、状态、备注; - 导出文件由浏览器直接下载,表头、合并单元格与字体已按打印模板预设。 ### 11.2 赛事项目包 @@ -613,6 +626,8 @@ > 大屏的标题字体与正文字体取自「系统设置 → 赛事信息 → 字体」(见 14.3):可选四款内置字体,也可以上传自己的字体;上传字体加载失败时自动回落到默认字体,画面不会出现方块或空白。 +> 除了 Console 开出来的本机大屏窗口,**局域网设备(另一台电脑、电视浏览器)打开 `http://<主机IP>:19540/display` 也能当大屏用**。这类设备会出现在管理端「大屏控制 → 在线大屏」列表里(标记为「局域网浏览器」),也能被单独切页,用法见第 9 节;它们不受 Console 的 kiosk、防熄屏控制(另见常见问题 Q29)。 + ### 17.2 轮播与锁定 - Console 启动的多屏大屏窗口默认**每屏锁定一页**,不轮播; diff --git a/docs/常见问题.md b/docs/常见问题.md index 92919fd..44a3a3c 100644 --- a/docs/常见问题.md +++ b/docs/常见问题.md @@ -137,8 +137,8 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 以前右上角还有一个 `vN` 数据版本号角标,**现在已经去掉,任何情况下都不再显示**。要核对某块屏有没有跟上服务端,看管理端仪表盘「实时连接」里的「版本 N」。 -**Q28:「在线看板」为什么显示未知?** -页面上那一块是本地占位节点:系统不按「指令送达」推断大屏是否在线,也不展示每块屏的实际上报状态,所以「在线」列恒为未知,卡片上写着「在线状态未知(占位)」;「最近下发」只是本机下发指令的时间,**不代表大屏在线**。以现场大屏的实际画面为准。 +**Q28:大屏控制页的「在线大屏」列表显示的是什么?** +显示的是**真实**的在线大屏实例,直接读服务端 `GET /api/display/instances`,打开页面时每 5 秒自动刷新一次:本机用 Console 打开的大屏窗口、局域网设备打开 `/display` 连上的浏览器都在里面(后者没有屏号,标记为「局域网浏览器」),每行显示实例 ID、在线 / 离线、当前所在页、来源地址与最后心跳,服务端判定落后时还会多一个「落后」徽标。以前这里确实是本机占位、恒显示「未知」,**那个占位现在已经被真实列表取代了**。离线实例会在服务端保留一段时间;列表读不到时页面提示「读取在线大屏失败」,并保留上一次的结果,不会清空。 **Q29:安卓电视能用吗?** 可以,用电视自带浏览器访问 `http://<主机IP>:19540/display`。但它不受 Console 的 kiosk、防熄屏控制,字体与动画表现以实际设备为准。 @@ -219,7 +219,7 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 ## 导出与导入 **Q43:能导出哪些文件?** -四类 Excel:总表、全部(多 sheet)、分项成绩表、单位积分明细;另外可导出赛事项目包(zip)与规则包(YAML)。 +结果导出现在有五类 Excel:**总表**(`总积分榜.xlsx`)、**全部**(`赛事成绩总表.xlsx`,多 sheet)、**全部(单页)**(`赛事成绩总表(单页).xlsx`,单张表平铺)、**分项成绩表**(`分项成绩表.xlsx`)、**单位积分明细**(`单位积分明细.xlsx`);「积分榜」页另有**导出总明细**(`单位积分总明细.xlsx`)与**导出该单位明细**(`单位积分明细.xlsx`)。另外可导出赛事项目包(zip)与规则包(YAML)。 **Q44:导出文件保存在哪?** 浏览器默认下载目录。服务端生成的临时包位于 `data\exports\`,下载完成后自动删除。 @@ -361,3 +361,39 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 - 编辑这些运动员时,「所属单位」下拉里没有这个单位,**必须改选另一个单位才能保存**。 所以更稳妥的做法是:先把名下运动员调到别的单位(或者干脆用「停用」代替「彻底删除」),再决定要不要彻底删除这个单位。 + +**Q65:大屏控制页为什么看不到局域网那台电视?** +先确认那台设备真的打开了 `http://<主机IP>:19540/display` 并且能连上主机(同一局域网、服务端端口 19540 已放行)。连上后它会出现在「大屏控制 → 在线大屏」列表里——这个列表每 5 秒自动刷新一次,刚打开时等几秒即可。另外注意: + +- 列表里在线实例排在前,离线实例也会保留一段时间(挂在后面),别只看第一屏就以为没有; +- 页面提示「读取在线大屏失败」时是列表没读出来,按提示刷新或检查登录状态(该接口需要记录员及以上),不是那台设备没连上; +- 本机「显示器」卡片只列**本机接的显示器**,局域网设备没有屏号,不会出现在那张卡片里,只会在「在线大屏」列表里显示为「局域网浏览器」。 + +**Q66:怎么给某一块屏单独切页?** +在「大屏控制 → 在线大屏」列表里找到那一行,用「当前页」旁的下拉选好页名,点「切到此页」——**只切这一台**,其他屏不受影响。本机显示器也可以直接在「显示器」卡片对应那一行的下拉 + 「切到此页」切。要让**所有大屏一起切**,才用上方「切页」卡片选页后点「立即跳转」。离线实例的下拉和按钮是灰的,切不了。 + +**Q67:切页时提示「目标大屏已离线」怎么办?** +这条提示只在**点名切页、但一块屏都没收到指令**时出现,说明目标实例已经离线或实例 ID 失效。处理办法: + +1. 在「在线大屏」列表里看那一行的状态与「最后心跳」,确认大屏窗口 / 浏览器还开着、网络正常; +2. 等它重连(列表每 5 秒一刷,重连后会变回「在线」)再切; +3. 列表里根本没有它时,说明大屏没连上服务端,先解决连接再切页。 + +离线实例切不动是正常的:指令发不到它,系统不会谎报「已切换」。 + +**Q68:导出里「全部」和「全部(单页)」有什么区别?** +两者内容同源,覆盖的都是全部小项的全部报名 / 成绩,区别在排版: + +- **全部**(`赛事成绩总表.xlsx`):一个工作簿里每个小项一张分表(多 sheet),适合归档打印; +- **全部(单页)**(`赛事成绩总表(单页).xlsx`):一个工作簿**只有一张表**,把所有单位、所有项目、所有成绩平铺在一起;每行前面带「大项 / 小项 / 组别」三列,脱离分表也能看懂。列是:大项、小项、组别、名次、号码、单位、运动员/队、成绩、积分、状态、备注。适合筛选、排序与二次统计。 + +**Q69:怎么把一个单位的积分明细导出成表格?** +两种方式: + +1. **只导一个单位**:到「积分榜」页展开该单位的明细,点右侧明细面板头部的「导出该单位明细」,得到 `单位积分明细.xlsx`(逐条列出每一分的来源,末行是「合计」); +2. **一次导全部单位**:点「积分榜」页右上角的「导出总明细」,得到 `单位积分总明细.xlsx`——一张带「单位」列的总表,外加每个**有数据**的单位一张分表(没有积分的单位不出分表)。 + +团体奖牌榜那一列的「明细」展开的是**同一个单位积分明细面板**,所以也用「导出该单位明细」。另外「导出 → 结果导出」里的「单位积分明细」也能先选一个单位再导出。这些导出都是只读下载,访客也能用。 + +**Q70:个人积分榜能导出明细吗?** +不能。个人积分榜只有榜单(运动员、单位、个人总分与奖牌数),**没有明细列,也没有对应的明细导出**。需要看某个人 / 某个单位的得分来源时,用「导出总明细」或「导出该单位明细」:记到个人的流水会按折算系数并入所属单位的合计列出(见 Q14、Q18)。 diff --git a/server/internal/export/flat.go b/server/internal/export/flat.go new file mode 100644 index 0000000..898fc35 --- /dev/null +++ b/server/internal/export/flat.go @@ -0,0 +1,82 @@ +package export + +import ( + "database/sql" + "io" + + "github.com/xuri/excelize/v2" +) + +// flatSheet 是「全部成绩(单页)」唯一那张表的表名。 +const flatSheet = "全部成绩" + +// FlatWorkbook 是「全部成绩(单页)」的内容:所有小项、所有成绩平铺在一张表里。 +// 与「全部」多 sheet 工作簿同源(都来自 LoadEventWorkbook),只是排版方式不同。 +type FlatWorkbook struct { + Meta Meta + Sheets []EventSheet +} + +// flatColumns 是单页表的列:在分项成绩列前补「大项 / 小项 / 组别」三列, +// 让每行脱离 sheet 标题也能自解释;后 8 列直接复用 eventColumns,避免两处口径漂移。 +var flatColumns = append([]column{ + {"大项", 14, kindText}, + {"小项", 18, kindText}, + {"组别", 14, kindText}, +}, eventColumns...) + +// ExportFlatXLSX 导出「全部成绩(单页)」到 dst。 +func ExportFlatXLSX(db *sql.DB, dst string, opts Options) error { + wb, err := LoadFlatWorkbook(db, opts) + if err != nil { + return err + } + f, err := flatFile(wb) + if err != nil { + return err + } + return saveWorkbook(f, dst) +} + +// WriteFlatXLSX 把「全部成绩(单页)」写成 xlsx 流,供 HTTP 直接响应下载。 +func WriteFlatXLSX(w io.Writer, wb FlatWorkbook) error { + f, err := flatFile(wb) + if err != nil { + return err + } + return f.Write(w) +} + +// LoadFlatWorkbook 读取全部小项(eventID = 0)的成绩,保留「全部」多 sheet 版里的小项顺序。 +func LoadFlatWorkbook(db *sql.DB, opts Options) (FlatWorkbook, error) { + events, err := LoadEventWorkbook(db, 0, opts) + if err != nil { + return FlatWorkbook{}, err + } + return FlatWorkbook{Meta: events.Meta, Sheets: events.Sheets}, nil +} + +func flatFile(wb FlatWorkbook) (*excelize.File, error) { + return buildWorkbook(flatSheet, func(f *excelize.File, st *styleSet) error { + meta := wb.Meta.normalized() + name := sanitizeSheetName(flatSheet) + if err := addSheet(f, name); err != nil { + return err + } + return writeSheet(f, st, name, meta, sheetTitle(meta, flatSheet), flatColumns, flatRows(wb.Sheets)) + }) +} + +// flatRows 把小项 sheet 按原顺序拼成一份平铺行,每行前面补上所属的大项 / 小项 / 组别。 +// 大项、小项、组别直接取 EventSheet 的结构化字段(LoadEventWorkbook 建 sheet 时已填好), +// 不去解析 sheet 标题字符串。 +func flatRows(sheets []EventSheet) [][]any { + var out [][]any + for _, sh := range sheets { + ctx := []any{sh.SportName, sh.EventName, sh.Category} + for _, row := range eventRows(sh.Rows) { + out = append(out, append(append([]any{}, ctx...), row...)) + } + } + return out +} diff --git a/server/internal/export/flat_test.go b/server/internal/export/flat_test.go new file mode 100644 index 0000000..1ba39dd --- /dev/null +++ b/server/internal/export/flat_test.go @@ -0,0 +1,183 @@ +package export + +import ( + "database/sql" + "path/filepath" + "testing" +) + +// seedSecondSport 再造一个大项下的小项(1 个组别 1 条成绩), +// 用于验证「大项 / 小项 / 组别」三列在多个小项之间不会串行。 +func seedSecondSport(t *testing.T, db *sql.DB) { + t.Helper() + stmts := []string{ + `INSERT INTO sports(code, name) VALUES ('SP2', '游泳')`, + `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status) + VALUES (2, 'E2', '50米自由泳', 'individual', 'auto', 'time', 'asc', 'published')`, + `INSERT INTO event_categories(event_id, name, sort_order) VALUES (2, '女子', 1)`, + `INSERT INTO event_score_channels(event_id, scope, basis, profile_id) + SELECT 2, 'athlete', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`, + `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (2, 2, 1, 1, '201')`, + `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state) + VALUES (2, 3, 1, '28.30', 28.30, 's', 'published')`, + `INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points) + VALUES (1, 1, 2, 3, 3, 'result', 3, 9)`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造数据 %q: %v", s, err) + } + } +} + +// 单页导出:只有一个 sheet,行数是全部小项 sheet 行数之和,前三列标注所属小项。 +func TestExportFlatXLSX(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + seedSecondSport(t, db) + + dst := filepath.Join(t.TempDir(), "单页.xlsx") + if err := ExportFlatXLSX(db, dst, Options{}); err != nil { + t.Fatalf("导出单页总表: %v", err) + } + f := openXLSX(t, dst) + defer func() { _ = f.Close() }() + + if got := f.GetSheetList(); !equalStrings(got, []string{flatSheet}) { + t.Fatalf("sheet 列表 = %v,期望只有 %s", got, flatSheet) + } + rows, err := f.GetRows(flatSheet) + if err != nil { + t.Fatalf("读取单页总表: %v", err) + } + // 4 行版式 + 小项 1 的 2 条成绩(两个单位各 1 条)+ 小项 2 的 1 条成绩 + if len(rows) != 7 { + t.Fatalf("单页总表行数 = %d,期望 7", len(rows)) + } + wantHeader := []string{"大项", "小项", "组别", "名次", "号码", "单位", "运动员/队", "成绩", "积分", "状态", "备注"} + for i, want := range wantHeader { + if got := cell(rows, 3, i); got != want { + t.Errorf("表头第 %d 列 = %q,期望 %q", i, got, want) + } + } + // 小项 1:田径 / 100米跑 / 男子。 + first := []string{"田径", "100米跑", "男子", "1", "101", "第一分公司", "张三", "11.58 秒", "10", "有效"} + for i, want := range first { + if got := cell(rows, 4, i); got != want { + t.Errorf("第 1 行第 %d 列 = %q,期望 %q", i, got, want) + } + } + // 同一个小项的第二条成绩:三列保持该小项,不随行变。 + sameEvent := []string{"田径", "100米跑", "男子", "2", "102", "第二分公司", "李四", "11.90 秒", "8", "有效"} + for i, want := range sameEvent { + if got := cell(rows, 5, i); got != want { + t.Errorf("第 2 行第 %d 列 = %q,期望 %q", i, got, want) + } + } + // 小项 2:游泳 / 50米自由泳 / 女子——三列必须跟着换,不能沿用上一个小项。 + second := []string{"游泳", "50米自由泳", "女子", "1", "201", "第一分公司", "张三", "28.30 秒", "9", "有效"} + for i, want := range second { + if got := cell(rows, 6, i); got != want { + t.Errorf("第 3 行第 %d 列 = %q,期望 %q", i, got, want) + } + } +} + +// 与「全部」(all.xlsx)逐格一致:同一个小项的行,除新增三列外每个单元格都相同。 +func TestFlatMatchesAllXLSX(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + seedSecondSport(t, db) + + allDst := filepath.Join(t.TempDir(), "全部.xlsx") + if err := ExportAllXLSX(db, allDst, Options{}); err != nil { + t.Fatalf("导出全部: %v", err) + } + flatDst := filepath.Join(t.TempDir(), "单页.xlsx") + if err := ExportFlatXLSX(db, flatDst, Options{}); err != nil { + t.Fatalf("导出单页总表: %v", err) + } + + allF := openXLSX(t, allDst) + defer func() { _ = allF.Close() }() + flatF := openXLSX(t, flatDst) + defer func() { _ = flatF.Close() }() + + flatRows, err := flatF.GetRows(flatSheet) + if err != nil { + t.Fatalf("读取单页总表: %v", err) + } + + // 判断哪个分项 sheet 对应哪组结构化数据:直接重新加载一次,顺序与写 sheet 时一致, + // 不用去解析 sheet 名或标题字符串。 + wb, err := LoadAllWorkbook(db, Options{}) + if err != nil { + t.Fatalf("读取全部: %v", err) + } + // 「全部」前 3 张是总表(总积分榜 / 团体奖牌榜 / 个人积分奖牌榜),其后才是分项 sheet。 + sheets := allF.GetSheetList() + if len(sheets) != 3+len(wb.Events.Sheets) { + t.Fatalf("全部导出 sheet 列表 = %v,与 %d 个分项对不上", sheets, len(wb.Events.Sheets)) + } + + flatRow, compared := 4, 0 + for si, name := range sheets[3:] { + sh := wb.Events.Sheets[si] + rows, err := allF.GetRows(name) + if err != nil { + t.Fatalf("读取分项 %s: %v", name, err) + } + // 版式为 1 标题 / 2 副标题 / 3 空行 / 4 表头,数据从第 5 行(索引 4)起。 + dataRows := len(rows) - 4 + for i := 0; i < dataRows; i++ { + for c := 0; c < len(eventColumns); c++ { + if got, want := cell(flatRows, flatRow+i, c+3), cell(rows, 4+i, c); got != want { + t.Errorf("分项 %s 第 %d 行第 %d 列:单页 %q,全部 %q", name, i, c, got, want) + } + } + // 新增的三列必须是该 sheet 所属的大项 / 小项 / 组别。 + for c, want := range []string{sh.SportName, sh.EventName, sh.Category} { + if got := cell(flatRows, flatRow+i, c); got != want { + t.Errorf("分项 %s 第 %d 行第 %d 列 = %q,期望 %q", name, i, c, got, want) + } + } + compared++ + } + flatRow += dataRows + } + + // 行数 = 全部小项 sheet 的行数之和(+ 4 行版式)。 + if flatRow != len(flatRows) { + t.Errorf("单页总表行数 = %d,期望 %d", len(flatRows), flatRow) + } + if compared != 3 { + t.Fatalf("比对的数据行数 = %d,期望 3", compared) + } + t.Logf("单页数据行三列: %v / %v / %v", flatRows[4][:3], flatRows[5][:3], flatRows[6][:3]) +} + +// 空赛事:返回一张只有标题与表头的工作簿,不报错。 +func TestExportFlatXLSXEmptyEvent(t *testing.T) { + db := openEventDB(t) + + dst := filepath.Join(t.TempDir(), "单页.xlsx") + if err := ExportFlatXLSX(db, dst, Options{}); err != nil { + t.Fatalf("空赛事导出单页总表: %v", err) + } + f := openXLSX(t, dst) + defer func() { _ = f.Close() }() + + if got := f.GetSheetList(); !equalStrings(got, []string{flatSheet}) { + t.Fatalf("sheet 列表 = %v,期望只有 %s", got, flatSheet) + } + rows, err := f.GetRows(flatSheet) + if err != nil { + t.Fatalf("读取单页总表: %v", err) + } + if len(rows) != 4 { // 标题 / 副标题 / 空行 / 表头,没有数据行 + t.Fatalf("空赛事单页总表行数 = %d,期望 4", len(rows)) + } + if got := cell(rows, 3, 0); got != "大项" { + t.Errorf("空赛事表头首列 = %q,期望 大项", got) + } +} diff --git a/server/internal/export/team.go b/server/internal/export/team.go index 5a9d4bb..43d0116 100644 --- a/server/internal/export/team.go +++ b/server/internal/export/team.go @@ -81,13 +81,14 @@ func teamDetailFile(wb TeamDetailWorkbook) (*excelize.File, error) { }) } -// teamDetailRows 把明细行排成表格。与 REST 的单位积分明细同口径: +// teamDetailDataRows 生成明细的数据行(不含「合计」)并返回合计值。与 REST 的单位积分明细同口径: // 记到个人的流水按 individual_to_team_weight 折算后展示,合计即各行之和。 -func teamDetailRows(rows []TeamDetailRow, weight float64) [][]any { +// 总明细表复用它拼各单位的数据行,保证单表与总表逐格一致。 +func teamDetailDataRows(rows []TeamDetailRow, weight float64) ([][]any, float64) { if weight <= 0 { weight = 1 } - out := make([][]any, 0, len(rows)+1) + out := make([][]any, 0, len(rows)) var total float64 for i, r := range rows { points := r.Points @@ -104,8 +105,14 @@ func teamDetailRows(rows []TeamDetailRow, weight float64) [][]any { rank, r.ResultText, r.Source, points, r.Remark, }) } - if len(rows) > 0 { - out = append(out, []any{"合计", "", "", "", "", "", "", "", total, ""}) + return out, total +} + +// teamDetailRows 把明细行排成表格:数据行后跟一行「合计」;没有流水时不加合计行。 +func teamDetailRows(rows []TeamDetailRow, weight float64) [][]any { + data, total := teamDetailDataRows(rows, weight) + if len(data) == 0 { + return data } - return out + return append(data, []any{"合计", "", "", "", "", "", "", "", total, ""}) } diff --git a/server/internal/export/team_all.go b/server/internal/export/team_all.go new file mode 100644 index 0000000..d73ba6d --- /dev/null +++ b/server/internal/export/team_all.go @@ -0,0 +1,173 @@ +package export + +import ( + "database/sql" + "io" + "sort" + + "github.com/xuri/excelize/v2" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" +) + +// allTeamDetailsSheet 是总明细工作簿第一张总表的表名。 +const allTeamDetailsSheet = "积分总明细" + +// TeamDetailSheet 是总明细里某个单位的一张分表:列定义与单位明细完全一致。 +// 只有有明细行的单位才会有分表(Rows 恒非空)。 +type TeamDetailSheet struct { + Team domain.Team + Rows []TeamDetailRow +} + +// AllTeamDetailsWorkbook 是「所有单位积分明细」(总明细)的内容: +// 第一张总表把所有单位的明细合在一起,其后每个有明细行的单位一张分表。 +type AllTeamDetailsWorkbook struct { + Meta Meta + Sheets []TeamDetailSheet + IndividualToTeamWeight float64 +} + +// allTeamDetailColumns 是总表的列:在单位明细列的最前面加一列「单位」。 +var allTeamDetailColumns = append([]column{{"单位", 22, kindText}}, teamDetailColumns...) + +// ExportAllTeamDetailsXLSX 导出所有单位的积分明细(总表 + 每单位一张分表),落盘到 dst。 +func ExportAllTeamDetailsXLSX(db *sql.DB, dst string, opts Options) error { + wb, err := LoadAllTeamDetailsWorkbook(db, opts) + if err != nil { + return err + } + f, err := allTeamDetailsFile(wb) + if err != nil { + return err + } + return saveWorkbook(f, dst) +} + +// WriteAllTeamDetailsXLSX 把总明细写成 xlsx 流,供 HTTP 直接响应下载。 +func WriteAllTeamDetailsXLSX(w io.Writer, wb AllTeamDetailsWorkbook) error { + f, err := allTeamDetailsFile(wb) + if err != nil { + return err + } + return f.Write(w) +} + +func allTeamDetailsFile(wb AllTeamDetailsWorkbook) (*excelize.File, error) { + return buildWorkbook(allTeamDetailsSheet, func(f *excelize.File, st *styleSet) error { + meta := wb.Meta.normalized() + // 先登记总表名:分表即使和总表同名也会自动补后缀,不会撞名覆盖。 + namer := &sheetNamer{} + master := namer.add(allTeamDetailsSheet) + if err := addSheet(f, master); err != nil { + return err + } + if err := writeSheet(f, st, master, meta, sheetTitle(meta, allTeamDetailsSheet), + allTeamDetailColumns, allTeamDetailRows(wb.Sheets, wb.IndividualToTeamWeight)); err != nil { + return err + } + + for _, s := range wb.Sheets { + unit := displayName(s.Team.Name, s.Team.ShortName) + name := namer.add(unit + "积分明细") + if err := addSheet(f, name); err != nil { + return err + } + title := sheetTitle(meta, unit+" · 积分明细") + if err := writeSheet(f, st, name, meta, title, teamDetailColumns, + teamDetailRows(s.Rows, wb.IndividualToTeamWeight)); err != nil { + return err + } + } + return nil + }) +} + +// allTeamDetailRows 把各单位明细拼成一张总表。 +// 逐单位复用 teamDetailDataRows(因此序号在每个单位内重新从 1 开始,折算口径与单位明细完全一致), +// 末尾补一行跨单位合计;即使一条流水都没有也保留合计行,保持版式一致。 +func allTeamDetailRows(sheets []TeamDetailSheet, weight float64) [][]any { + var out [][]any + var total float64 + for _, s := range sheets { + unit := displayName(s.Team.Name, s.Team.ShortName) + rows, sub := teamDetailDataRows(s.Rows, weight) + total += sub + for _, row := range rows { + out = append(out, append([]any{unit}, row...)) + } + } + // 列序:单位 / 序号 / 大项 / 小项 / 组别 / 运动员 / 名次 / 成绩 / 来源 / 积分 / 备注。 + return append(out, []any{"合计", "", "", "", "", "", "", "", "", total, ""}) +} + +// LoadAllTeamDetailsWorkbook 读取全量单位的积分流水。 +// 单位顺序沿用「单位总分榜」(得分高的在前),与管理面板上看到的顺序一致; +// 榜上没有的单位(例如停用且没有任何流水/奖牌)按 teams.sort_order 排在其后。 +// 只有真正有明细行的单位才进 Sheets:没有流水的单位既没有信息量, +// 也不能把「彻底删除」(假删除)后无流水的单位名字重新印进导出件。 +func LoadAllTeamDetailsWorkbook(db *sql.DB, opts Options) (AllTeamDetailsWorkbook, error) { + if db == nil { + return AllTeamDetailsWorkbook{}, ErrNoDatabase + } + meta, settings, err := resolveMeta(db, opts.Meta) + if err != nil { + return AllTeamDetailsWorkbook{}, err + } + // 全量单位表:停用的单位也可能有历史流水,明细要能查到。 + teams, err := loadTeams(db, false) + if err != nil { + return AllTeamDetailsWorkbook{}, err + } + order, err := teamStandingsOrder(db, opts) + if err != nil { + return AllTeamDetailsWorkbook{}, err + } + sort.SliceStable(teams, func(i, j int) bool { + oi, iok := order[teams[i].ID] + oj, jok := order[teams[j].ID] + if iok != jok { + return iok + } + if iok { + return oi < oj + } + // 都不在榜上时保持 loadTeams 的 sort_order, code 顺序。 + return false + }) + + sheets := make([]TeamDetailSheet, 0, len(teams)) + for _, t := range teams { + rows, err := loadTeamDetailRows(db, t.ID) + if err != nil { + return AllTeamDetailsWorkbook{}, err + } + // 没有明细行就不出分表。已隐藏(deleted_at 非空)但有流水的单位仍在榜单上, + // 照常出表;隐藏且无流水的单位不出表,否则等于把藏起来的条目印在导出件里。 + if len(rows) == 0 { + continue + } + sheets = append(sheets, TeamDetailSheet{Team: t, Rows: rows}) + } + return AllTeamDetailsWorkbook{ + Meta: meta, + Sheets: sheets, + IndividualToTeamWeight: parseFloatDefault(settings["standings.individual_to_team_weight"], 1), + }, nil +} + +// teamStandingsOrder 返回 单位ID → 总积分榜名次(0 起)。 +// 名次规则只在 domain 里有一份,这里直接复用总积分榜的结果,不另算一套排序。 +func teamStandingsOrder(db *sql.DB, opts Options) (map[int64]int, error) { + wb, err := LoadStandingsWorkbook(db, opts) + if err != nil { + return nil, err + } + order := make(map[int64]int, len(wb.Team)) + for i, r := range wb.Team { + if _, ok := order[r.TeamID]; !ok { + order[r.TeamID] = i + } + } + return order, nil +} diff --git a/server/internal/export/team_all_test.go b/server/internal/export/team_all_test.go new file mode 100644 index 0000000..0de7076 --- /dev/null +++ b/server/internal/export/team_all_test.go @@ -0,0 +1,450 @@ +package export + +import ( + "bytes" + "database/sql" + "path/filepath" + "strings" + "testing" + + "github.com/xuri/excelize/v2" +) + +const masterSheetName = "积分总明细" + +// insertTeam 造一个单位;返回主键。 +func insertTeam(t *testing.T, db *sql.DB, code, name, short string, sortOrder int, active bool) int64 { + t.Helper() + res, err := db.Exec( + `INSERT INTO teams(code, name, short_name, sort_order, active) VALUES (?, ?, ?, ?, ?)`, + code, name, short, sortOrder, active) + if err != nil { + t.Fatalf("造单位 %s: %v", code, err) + } + id, err := res.LastInsertId() + if err != nil { + t.Fatalf("取单位 id: %v", err) + } + return id +} + +// insertLedger 给单位造一条人工调整流水(不挂小项,够触发一条明细行)。 +func insertLedger(t *testing.T, db *sql.DB, teamID int64, points float64) { + t.Helper() + if _, err := db.Exec( + `INSERT INTO score_ledger(team_id, source_type, source_id, points) VALUES (?, 'adjustment', 99, ?)`, + teamID, points); err != nil { + t.Fatalf("造单位 %d 流水: %v", teamID, err) + } +} + +// hideTeam 按 hideTeam 的真实语义「假删除」:只置 deleted_at 与 active = 0,数据全部保留。 +func hideTeam(t *testing.T, db *sql.DB, teamID int64) { + t.Helper() + if _, err := db.Exec( + `UPDATE teams SET deleted_at = datetime('now'), active = 0 WHERE id = ?`, teamID); err != nil { + t.Fatalf("隐藏单位 %d: %v", teamID, err) + } +} + +// 总表:第一列是「单位」,行数 = 各单位明细行数之和 + 合计行;分表与单位对得上。 +func TestExportAllTeamDetailsXLSX(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + + dst := filepath.Join(t.TempDir(), "总明细.xlsx") + if err := ExportAllTeamDetailsXLSX(db, dst, Options{}); err != nil { + t.Fatalf("导出总明细: %v", err) + } + f, err := excelize.OpenFile(dst) + if err != nil { + t.Fatalf("重新打开 xlsx: %v", err) + } + defer func() { _ = f.Close() }() + + // 单位顺序与「单位总分榜」一致:T1 10 分在前,T2 8 分在后。 + want := []string{masterSheetName, "第一分公司积分明细", "第二分公司积分明细"} + if got := f.GetSheetList(); !equalStrings(got, want) { + t.Fatalf("sheet 列表 = %v,期望 %v", got, want) + } + + master, err := f.GetRows(masterSheetName) + if err != nil { + t.Fatalf("读取总表: %v", err) + } + // 4 行版式 + 2 条明细(每单位 1 条)+ 1 行合计 + if len(master) != 7 { + t.Fatalf("总表行数 = %d,期望 7", len(master)) + } + if got := cell(master, 3, 0); got != "单位" { + t.Errorf("总表表头首列 = %q,期望 单位", got) + } + if got := cell(master, 3, 1); got != "序号" { + t.Errorf("总表表头第二列 = %q,期望 序号", got) + } + if got := cell(master, 3, 9); got != "积分" { + t.Errorf("总表表头积分列 = %q,期望 积分", got) + } + if got := cell(master, 3, 10); got != "备注" { + t.Errorf("总表表头末列 = %q,期望 备注", got) + } + if got := cell(master, 4, 0); got != "第一分公司" { + t.Errorf("首行单位 = %q,期望 第一分公司", got) + } + if got := cell(master, 4, 1); got != "1" { + t.Errorf("首行序号 = %q,期望 1", got) + } + if got := cell(master, 4, 2); got != "田径" { + t.Errorf("首行大项 = %q,期望 田径", got) + } + if got := cell(master, 4, 9); got != "10" { + t.Errorf("首行积分 = %q,期望 10", got) + } + if got := cell(master, 5, 0); got != "第二分公司" { + t.Errorf("次行单位 = %q,期望 第二分公司", got) + } + if got := cell(master, 6, 0); got != "合计" { + t.Errorf("合计行首列 = %q,期望 合计", got) + } + if got := cell(master, 6, 9); got != "18" { + t.Errorf("总合计 = %q,期望 18", got) + } + + team, err := f.GetRows("第一分公司积分明细") + if err != nil { + t.Fatalf("读取分表: %v", err) + } + if len(team) != 6 { // 4 行版式 + 1 条明细 + 合计 + t.Fatalf("第一分公司分表行数 = %d,期望 6", len(team)) + } + if got := cell(team, 3, 0); got != "序号" { + t.Errorf("分表表头首列 = %q,期望 序号", got) + } + if got := cell(team, 5, 0); got != "合计" { + t.Errorf("分表合计行首列 = %q,期望 合计", got) + } +} + +// 记到个人的流水折算后,总表 / 分表 / 单位明细端点三者必须逐格一致。 +func TestAllTeamDetailsMatchesTeamDetail(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + // 折算系数取非 1,确保比对的是折算后的数字而不是原始分。 + if _, err := db.Exec(`UPDATE settings SET value = '0.5' WHERE key = 'standings.individual_to_team_weight'`); err != nil { + t.Fatalf("修改折算系数: %v", err) + } + + single := filepath.Join(t.TempDir(), "单位明细.xlsx") + if err := ExportTeamDetailXLSX(db, 1, single, Options{}); err != nil { + t.Fatalf("导出单位明细: %v", err) + } + all := filepath.Join(t.TempDir(), "总明细.xlsx") + if err := ExportAllTeamDetailsXLSX(db, all, Options{}); err != nil { + t.Fatalf("导出总明细: %v", err) + } + + fSingle := openXLSX(t, single) + defer func() { _ = fSingle.Close() }() + fAll := openXLSX(t, all) + defer func() { _ = fAll.Close() }() + + singleRows, err := fSingle.GetRows("第一分公司积分明细") + if err != nil { + t.Fatalf("读取单位明细分表: %v", err) + } + allRows, err := fAll.GetRows("第一分公司积分明细") + if err != nil { + t.Fatalf("读取总明细分表: %v", err) + } + if !sameRows(singleRows, allRows) { + t.Fatalf("分表与单位明细端点不一致:\n单位明细 %v\n总明细分表 %v", singleRows, allRows) + } + + master, err := fAll.GetRows(masterSheetName) + if err != nil { + t.Fatalf("读取总表: %v", err) + } + // 总表里同一单位的数据行:去掉首列「单位」后应与单位明细的数据行逐格相同。 + teamRows := masterUnitRows(master, "第一分公司") + singleData := singleRows[4 : len(singleRows)-1] + if len(teamRows) != len(singleData) { + t.Fatalf("总表中第一分公司数据行数 = %d,单位明细 %d", len(teamRows), len(singleData)) + } + for i := range teamRows { + if got := cell(teamRows, i, 0); got != "第一分公司" { + t.Errorf("总表第 %d 行单位列 = %q", i, got) + } + for c := range singleData[i] { + if got, want := cell(teamRows, i, c+1), cell(singleData, i, c); got != want { + t.Errorf("总表第 %d 行第 %d 列 = %q,单位明细 = %q", i, c, got, want) + } + } + } + + // 折算后的实际数字:10 × 0.5 = 5,合计也是 5。 + t.Logf("单位明细分表行: %v", singleRows[4]) + t.Logf("总明细总表对应行: %v", teamRows[0]) + t.Logf("总明细分表合计行: %v", allRows[len(allRows)-1]) + if got := cell(allRows, 4, 8); got != "5" { + t.Errorf("分表折算后积分 = %q,期望 5", got) + } + if got := cell(allRows, 5, 8); got != "5" { + t.Errorf("分表合计 = %q,期望 5", got) + } + if got := cell(teamRows, 0, 9); got != "5" { + t.Errorf("总表折算后积分 = %q,期望 5", got) + } + if got := cell(master, 6, 9); got != "9" { // (10 + 8) × 0.5 + t.Errorf("总表合计 = %q,期望 9", got) + } +} + +// 两个单位简称相同时,分表名必须能区分,不能撞名或覆盖。 +func TestAllTeamDetailsDedupesSheetNames(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + insertLedger(t, db, insertTeam(t, db, "T3", "第一分公司", "三公司", 3, true), 1) + + dst := filepath.Join(t.TempDir(), "总明细.xlsx") + if err := ExportAllTeamDetailsXLSX(db, dst, Options{}); err != nil { + t.Fatalf("导出总明细: %v", err) + } + f := openXLSX(t, dst) + defer func() { _ = f.Close() }() + + want := []string{masterSheetName, "第一分公司积分明细", "第二分公司积分明细", "第一分公司积分明细(2)"} + if got := f.GetSheetList(); !equalStrings(got, want) { + t.Fatalf("sheet 列表 = %v,期望 %v", got, want) + } + // 两张同名单位分表都要有内容,不能被后写的覆盖。 + for _, name := range []string{"第一分公司积分明细", "第一分公司积分明细(2)"} { + if _, err := f.GetRows(name); err != nil { + t.Errorf("读取 %s: %v", name, err) + } + } +} + +// 长单位名截断到 31 字后撞名时也要能区分。 +func TestAllTeamDetailsDedupesTruncatedSheetNames(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + insertLedger(t, db, insertTeam(t, db, "T8", strings.Repeat("甲", 35)+"A", "", 3, true), 1) + insertLedger(t, db, insertTeam(t, db, "T9", strings.Repeat("甲", 35)+"B", "", 4, true), 1) + + dst := filepath.Join(t.TempDir(), "总明细.xlsx") + if err := ExportAllTeamDetailsXLSX(db, dst, Options{}); err != nil { + t.Fatalf("导出总明细: %v", err) + } + f := openXLSX(t, dst) + defer func() { _ = f.Close() }() + + want := []string{ + masterSheetName, "第一分公司积分明细", "第二分公司积分明细", + strings.Repeat("甲", 31), strings.Repeat("甲", 28) + "(2)", + } + if got := f.GetSheetList(); !equalStrings(got, want) { + t.Fatalf("sheet 列表 = %v,期望 %v", got, want) + } +} + +// 空赛事(一个单位都没有)要返回可用工作簿,而不是报错。 +func TestAllTeamDetailsEmptyEvent(t *testing.T) { + db := openEventDB(t) + + dst := filepath.Join(t.TempDir(), "总明细.xlsx") + if err := ExportAllTeamDetailsXLSX(db, dst, Options{}); err != nil { + t.Fatalf("空赛事导出: %v", err) + } + f := openXLSX(t, dst) + defer func() { _ = f.Close() }() + + if got := f.GetSheetList(); !equalStrings(got, []string{masterSheetName}) { + t.Fatalf("sheet 列表 = %v,期望只有总表", got) + } + rows, err := f.GetRows(masterSheetName) + if err != nil { + t.Fatalf("读取总表: %v", err) + } + if len(rows) != 5 { // 4 行版式 + 空数据 + 合计行 + t.Fatalf("总表行数 = %d,期望 5", len(rows)) + } + if got := cell(rows, 3, 0); got != "单位" { + t.Errorf("空赛事表头首列 = %q,期望 单位", got) + } + if got := cell(rows, 4, 0); got != "合计" { + t.Errorf("空赛事合计行首列 = %q,期望 合计", got) + } + if got := cell(rows, 4, 9); got != "0" { + t.Errorf("空赛事合计 = %q,期望 0", got) + } +} + +// 没有流水的单位不出分表,也不进总表。 +func TestAllTeamDetailsSkipsTeamWithoutLedger(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + insertTeam(t, db, "T3", "第三分公司", "三公司", 3, true) + + dst := filepath.Join(t.TempDir(), "总明细.xlsx") + if err := ExportAllTeamDetailsXLSX(db, dst, Options{}); err != nil { + t.Fatalf("导出总明细: %v", err) + } + f := openXLSX(t, dst) + defer func() { _ = f.Close() }() + + want := []string{masterSheetName, "第一分公司积分明细", "第二分公司积分明细"} + if got := f.GetSheetList(); !equalStrings(got, want) { + t.Fatalf("sheet 列表 = %v,期望 %v(无流水单位不出表)", got, want) + } + master, err := f.GetRows(masterSheetName) + if err != nil { + t.Fatalf("读取总表: %v", err) + } + if len(master) != 7 { // 仍只有 T1、T2 两条明细 + t.Fatalf("总表行数 = %d,期望 7", len(master)) + } + if got := masterUnitRows(master, "第三分公司"); len(got) != 0 { + t.Errorf("无流水单位不应出现在总表: %v", got) + } +} + +// 已隐藏(deleted_at 非空)的单位:有流水照常出分表(它在总积分榜上),无流水绝不出表。 +func TestAllTeamDetailsHiddenTeam(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + // 隐藏但有流水:在总积分榜上(domain 会为流水兜底出行),导出必须与榜单一致。 + scored := insertTeam(t, db, "H1", "被隐藏但有分", "隐藏一", 3, false) + insertLedger(t, db, scored, 7) + hideTeam(t, db, scored) + // 隐藏且无流水:正是用户想藏起来的条目,不能出现在导出件的任何地方。 + silent := insertTeam(t, db, "H2", "被隐藏且无分", "隐藏二", 4, false) + hideTeam(t, db, silent) + + dst := filepath.Join(t.TempDir(), "总明细.xlsx") + if err := ExportAllTeamDetailsXLSX(db, dst, Options{}); err != nil { + t.Fatalf("导出总明细: %v", err) + } + f := openXLSX(t, dst) + defer func() { _ = f.Close() }() + + // 有流水的隐藏单位:单位总分榜第 3 名(10 / 8 / 7),分表必须存在。 + want := []string{masterSheetName, "第一分公司积分明细", "第二分公司积分明细", "被隐藏但有分积分明细"} + if got := f.GetSheetList(); !equalStrings(got, want) { + t.Fatalf("sheet 列表 = %v,期望 %v", got, want) + } + rows, err := f.GetRows("被隐藏但有分积分明细") + if err != nil { + t.Fatalf("读取隐藏单位分表: %v", err) + } + if got := cell(rows, 4, 8); got != "7" { + t.Errorf("隐藏单位分表积分 = %q,期望 7", got) + } + + master, err := f.GetRows(masterSheetName) + if err != nil { + t.Fatalf("读取总表: %v", err) + } + if got := masterUnitRows(master, "被隐藏但有分"); len(got) != 1 { + t.Errorf("有流水的隐藏单位应出现在总表,得到 %v", got) + } + // 无流水的隐藏单位:sheet 与总表里都不得出现它的名字。 + for _, name := range f.GetSheetList() { + if strings.Contains(name, "无分") { + t.Errorf("无流水的隐藏单位不应出分表,却看到 %q", name) + } + } + if got := masterUnitRows(master, "被隐藏且无分"); len(got) != 0 { + t.Errorf("无流水的隐藏单位不应出现在总表: %v", got) + } +} + +// 所有单位都没有流水:只剩一张总表,且生成的文件仍能被 excelize 正常解析。 +func TestAllTeamDetailsNoLedgerAtAll(t *testing.T) { + db := openEventDB(t) + insertTeam(t, db, "T1", "第一分公司", "一公司", 1, true) + insertTeam(t, db, "T2", "第二分公司", "二公司", 2, true) + + dst := filepath.Join(t.TempDir(), "总明细.xlsx") + if err := ExportAllTeamDetailsXLSX(db, dst, Options{}); err != nil { + t.Fatalf("无流水导出: %v", err) + } + + // 真正的证据:重新打开文件解析,而不是只看写文件有没有报错。 + f := openXLSX(t, dst) + defer func() { _ = f.Close() }() + if got := f.GetSheetList(); !equalStrings(got, []string{masterSheetName}) { + t.Fatalf("sheet 列表 = %v,期望只有总表", got) + } + rows, err := f.GetRows(masterSheetName) + if err != nil { + t.Fatalf("读取总表: %v", err) + } + if len(rows) != 5 { // 4 行版式 + 空数据 + 合计行 + t.Fatalf("总表行数 = %d,期望 5", len(rows)) + } + if got := cell(rows, 3, 0); got != "单位" { + t.Errorf("总表表头首列 = %q,期望 单位", got) + } + if got := cell(rows, 4, 0); got != "合计" { + t.Errorf("合计行首列 = %q,期望 合计", got) + } +} + +func TestWriteAllTeamDetailsXLSXStream(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + + wb, err := LoadAllTeamDetailsWorkbook(db, Options{}) + if err != nil { + t.Fatalf("读取总明细: %v", err) + } + var buf bytes.Buffer + if err := WriteAllTeamDetailsXLSX(&buf, wb); err != nil { + t.Fatalf("流式导出总明细: %v", err) + } + f, err := excelize.OpenReader(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatalf("打开流式导出的 xlsx: %v", err) + } + defer func() { _ = f.Close() }() + if got := len(f.GetSheetList()); got != 3 { + t.Errorf("sheet 数 = %d,期望 3", got) + } +} + +func openXLSX(t *testing.T, path string) *excelize.File { + t.Helper() + f, err := excelize.OpenFile(path) + if err != nil { + t.Fatalf("打开 %s: %v", path, err) + } + return f +} + +// sameRows 逐格比较两份表格(越界按空字符串处理)。 +func sameRows(a, b [][]string) bool { + n := len(a) + if len(b) > n { + n = len(b) + } + for r := 0; r < n; r++ { + for c := 0; c < 12; c++ { + if cell(a, r, c) != cell(b, r, c) { + return false + } + } + } + return true +} + +// masterUnitRows 取总表里某个单位的数据行(排除合计行)。 +func masterUnitRows(rows [][]string, unit string) [][]string { + var out [][]string + for r, row := range rows { + if r < 4 || cell(rows, r, 0) != unit { + continue + } + out = append(out, row) + } + return out +} diff --git a/server/internal/httpapi/display_command_test.go b/server/internal/httpapi/display_command_test.go new file mode 100644 index 0000000..0c8ebc8 --- /dev/null +++ b/server/internal/httpapi/display_command_test.go @@ -0,0 +1,208 @@ +package httpapi_test + +import ( + "database/sql" + "errors" + "net/http" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" + "git.yeij.top/AskaEth/Scoreboard/server/internal/realtime" +) + +// waitDisplayAudit 取最后一条 display.command 审计的 after_json。 +// 审计中间件在响应写完之后才落库,所以这里给一小段重试窗口。 +func waitDisplayAudit(t *testing.T, env *testEnv) string { + t.Helper() + for i := 0; i < 100; i++ { + var after sql.NullString + err := env.ev.QueryRow( + `SELECT after_json FROM audit_logs WHERE action = 'display.command' ORDER BY id DESC LIMIT 1`).Scan(&after) + switch { + case err == nil: + return after.String + case errors.Is(err, sql.ErrNoRows): + time.Sleep(10 * time.Millisecond) + default: + t.Fatalf("查询审计: %v", err) + } + } + t.Fatal("等不到 display.command 的审计记录") + return "" +} + +// settingOrEmpty 读 settings 表里的点号键,缺行返回空串(没写过公告时就没有这一行)。 +func settingOrEmpty(t *testing.T, env *testEnv, key string) string { + t.Helper() + var v sql.NullString + err := env.ev.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&v) + switch { + case err == nil: + return v.String + case errors.Is(err, sql.ErrNoRows): + return "" + default: + t.Fatalf("读取配置 %s: %v", key, err) + } + return "" +} + +// newDisplayEnv 建一个带 recorder 客户端与指令桩的环境。 +func newDisplayEnv(t *testing.T, sender *strictDisplaySender) (*testEnv, *http.Client) { + t.Helper() + env := newTestEnvWith(t, func(o *httpapi.Options) { o.Display = sender }) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + return env, client +} + +// 带 targets 的指令只发给点名的实例,命中数与目标一并进审计。 +func TestDisplayCommandTargetsInstances(t *testing.T) { + sender := &strictDisplaySender{} + env, client := newDisplayEnv(t, sender) + + status, env2 := env.do(client, http.MethodPost, "/api/display/command", map[string]any{ + "action": "goto", + "payload": map[string]any{"page": "medals"}, + "targets": []string{"screen-a", "screen-b"}, + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("定向下发应 200,得到 %d %+v", status, env2.Error) + } + if len(sender.targets) != 1 || len(sender.targets[0]) != 2 || + sender.targets[0][0] != "screen-a" || sender.targets[0][1] != "screen-b" { + t.Fatalf("targets 应原样传给实时层,得到 %+v", sender.targets) + } + + after := waitDisplayAudit(t, env) + for _, want := range []string{`"action":"goto"`, `"targets":["screen-a","screen-b"]`, `"matched":2`} { + if !strings.Contains(after, want) { + t.Fatalf("审计应含 %s,得到 %s", want, after) + } + } +} + +// 不带 targets = 广播:实时层收到空 targets,公告这条链路照旧。 +func TestDisplayCommandBroadcastKeepsWorking(t *testing.T) { + sender := &strictDisplaySender{} + env, client := newDisplayEnv(t, sender) + + status, env2 := env.do(client, http.MethodPost, "/api/display/command", map[string]any{ + "action": "announce", + "payload": map[string]any{"text": "决赛推迟到 15:00", "seconds": 30}, + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("广播公告应 200,得到 %d %+v", status, env2.Error) + } + if len(sender.targets) != 1 || sender.targets[0] != nil { + t.Fatalf("不带 targets 时应按广播下发(nil),得到 %+v", sender.targets) + } + if got := settingValue(t, env, "display.announce_text"); got != "决赛推迟到 15:00" { + t.Fatalf("广播公告仍应落库,得到 %q", got) + } + if after := waitDisplayAudit(t, env); !strings.Contains(after, `"matched":1`) { + t.Fatalf("广播也要记命中数,得到 %s", after) + } +} + +// 目标全部离线:返回可读的 404,且公告不落库、不产生新的 settings 变更。 +func TestDisplayCommandOfflineTargetReturns404(t *testing.T) { + matched := 0 + sender := &strictDisplaySender{matched: &matched} + env, client := newDisplayEnv(t, sender) + + status, env2 := env.do(client, http.MethodPost, "/api/display/command", map[string]any{ + "action": "announce", + "payload": map[string]any{"text": "这条不该落库", "seconds": 30}, + "targets": []string{"gone-screen"}, + }, true) + if status != http.StatusNotFound || env2.Error == nil { + t.Fatalf("目标离线应 404,得到 %d %+v", status, env2.Error) + } + if env2.Error.Message != "目标大屏已离线,请刷新在线列表" { + t.Fatalf("404 消息应为可读中文,得到 %q", env2.Error.Message) + } + if got := settingOrEmpty(t, env, "display.announce_text"); got != "" { + t.Fatalf("没命中任何屏时公告不该落库,得到 %q", got) + } + // 定向失败的请求没有成功审计(审计只记 2xx)。 + var n int + if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = 'display.command'`).Scan(&n); err != nil { + t.Fatalf("统计审计: %v", err) + } + if n != 0 { + t.Fatalf("失败的指令不该留 display.command 审计,得到 %d 条", n) + } +} + +// GET /api/display/instances 要把屏号带出来,方便面板把本机显示器与在线实例对上。 +func TestDisplayInstancesExposeScreen(t *testing.T) { + fake := &fakeRealtime{} + env := newTestEnvWith(t, func(o *httpapi.Options) { o.Realtime = fake }) + fake.db = env.ev + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + fake.instances = []realtime.Instance{ + {ID: "console-1", Online: true, Page: "standings", Screen: "2", RemoteAddr: "192.168.1.9:51000"}, + {ID: "lan-1", Online: true, Page: "medals"}, + } + status, env2 := env.do(client, http.MethodGet, "/api/display/instances", nil, false) + if status != http.StatusOK || !env2.OK { + t.Fatalf("读取大屏实例应 200,得到 %d %+v", status, env2.Error) + } + rows := decodeData[[]map[string]any](t, env2) + if len(rows) != 2 { + t.Fatalf("应返回 2 个实例,得到 %+v", rows) + } + if rows[0]["screen"] != "2" || rows[1]["screen"] != "" { + t.Fatalf("屏号字段应原样带出(无屏号为空串),得到 %+v", rows) + } + if rows[0]["page"] != "standings" || rows[1]["page"] != "medals" { + t.Fatalf("当前页字段应原样带出,得到 %+v", rows) + } +} + +// 权限:指令与实例列表都要求 recorder 及以上,guest 一律拒绝。 +func TestDisplayEndpointsRequireRecorder(t *testing.T) { + sender := &strictDisplaySender{} + env := newTestEnvWith(t, func(o *httpapi.Options) { o.Display = sender }) + guest, _ := env.client() + + if status, _ := env.do(guest, http.MethodPost, "/api/display/command", + map[string]any{"action": "goto", "payload": map[string]any{"page": "medals"}}, false); status == http.StatusOK { + t.Fatalf("guest 下发指令不该成功,得到 %d", status) + } + if status, _ := env.do(guest, http.MethodGet, "/api/display/instances", nil, false); status != http.StatusForbidden { + t.Fatalf("guest 读实例列表应 403,得到 %d", status) + } + + env, client := newDisplayEnv(t, sender) + if status, env2 := env.do(client, http.MethodPost, "/api/display/command", map[string]any{ + "action": "goto", "payload": map[string]any{"page": "medals"}, + }, true); status != http.StatusOK { + t.Fatalf("recorder 下发指令应 200,得到 %d %+v", status, env2.Error) + } + if status, env2 := env.do(client, http.MethodGet, "/api/display/instances", nil, false); status != http.StatusOK || !env2.OK { + t.Fatalf("recorder 读实例列表应 200,得到 %d %+v", status, env2.Error) + } +} + +// 未知 action 仍要被实时层拒收(走 strictDisplaySender 的同口径校验)。 +func TestDisplayCommandRejectsUnknownAction(t *testing.T) { + sender := &strictDisplaySender{} + env, client := newDisplayEnv(t, sender) + + status, env2 := env.do(client, http.MethodPost, "/api/display/command", + map[string]any{"action": "nope"}, true) + if status != http.StatusInternalServerError || env2.Error == nil { + t.Fatalf("未知 action 应被拒收,得到 %d %+v", status, env2.Error) + } + if len(sender.sent) != 0 { + t.Fatalf("未知 action 不该下发,得到 %v", sender.sent) + } +} diff --git a/server/internal/httpapi/export_flat_test.go b/server/internal/httpapi/export_flat_test.go new file mode 100644 index 0000000..6b690aa --- /dev/null +++ b/server/internal/httpapi/export_flat_test.go @@ -0,0 +1,123 @@ +package httpapi_test + +import ( + "net/http" + "net/url" + "testing" +) + +// 未登录(guest)能下载单页平铺总表:文件名、单 sheet、逐格与 all.xlsx 的分项 sheet 一致。 +func TestExportAllFlatXLSXGuest(t *testing.T) { + env := newTestEnv(t) + env.seed(t) + client, _ := env.client() + + status, header, body := env.getRaw(t, client, "/api/export/all-flat.xlsx") + if status != http.StatusOK { + t.Fatalf("guest 下载单页总表应 200,得到 %d %s", status, body) + } + if got := header.Get("Content-Type"); got != xlsxContentType { + t.Errorf("Content-Type = %q", got) + } + wantCD := "attachment; filename*=UTF-8''" + url.PathEscape("赛事成绩总表(单页).xlsx") + if got := header.Get("Content-Disposition"); got != wantCD { + t.Errorf("Content-Disposition = %q,期望 %q", got, wantCD) + } + + f := openWorkbook(t, body) + defer func() { _ = f.Close() }() + if got := f.GetSheetList(); !equalSheetList(got, []string{"全部成绩"}) { + t.Fatalf("sheet 列表 = %v,期望只有一张「全部成绩」", got) + } + flat, err := f.GetRows("全部成绩") + if err != nil { + t.Fatalf("读取单页总表: %v", err) + } + // 4 行版式 + 2 条报名(seed 造了 2 个单位各 1 条报名) + if len(flat) != 6 { + t.Fatalf("单页总表行数 = %d,期望 6", len(flat)) + } + for i, want := range []string{"大项", "小项", "组别", "名次", "号码", "单位", "运动员/队", "成绩", "积分", "状态", "备注"} { + if got := masterCell(flat, 3, i); got != want { + t.Errorf("表头第 %d 列 = %q,期望 %q", i, got, want) + } + } + if got := masterCell(flat, 4, 0); got != "田径" { + t.Errorf("首行大项 = %q,期望 田径", got) + } + if got := masterCell(flat, 4, 1); got != "100米跑" { + t.Errorf("首行小项 = %q,期望 100米跑", got) + } + if got := masterCell(flat, 4, 2); got != "男子" { + t.Errorf("首行组别 = %q,期望 男子", got) + } + if got := masterCell(flat, 4, 5); got != "第一分公司" { + t.Errorf("首行单位 = %q,期望 第一分公司", got) + } + + // 与 all.xlsx 的分项 sheet 逐格一致(除新增三列)。 + status, _, allBody := env.getRaw(t, client, "/api/export/all.xlsx") + if status != http.StatusOK { + t.Fatalf("下载全部导出应 200,得到 %d", status) + } + allF := openWorkbook(t, allBody) + defer func() { _ = allF.Close() }() + sheets := allF.GetSheetList() + if len(sheets) != 4 { // 3 张总表 + 1 个分项 + t.Fatalf("全部导出 sheet 列表 = %v", sheets) + } + flatRow, rows := 4, 0 + for _, name := range sheets[3:] { + eventRows, err := allF.GetRows(name) + if err != nil { + t.Fatalf("读取分项 %s: %v", name, err) + } + for i := 0; i < len(eventRows)-4; i++ { + for c := 0; c < 8; c++ { + if got, want := masterCell(flat, flatRow+i, c+3), masterCell(eventRows, 4+i, c); got != want { + t.Errorf("分项 %s 第 %d 行第 %d 列:单页 %q,全部 %q", name, i, c, got, want) + } + } + rows++ + } + flatRow += len(eventRows) - 4 + } + if rows != len(flat)-4 { + t.Errorf("比对行数 = %d,单页数据行 = %d", rows, len(flat)-4) + } +} + +// 新增导出不能带坏既有导出:权限与 sheet 结构全部保原样。 +func TestExistingExportsUnchanged(t *testing.T) { + env := newTestEnv(t) + env.seed(t) + client, _ := env.client() + + cases := []struct { + path string + file string + sheets []string + }{ + {"/api/export/standings.xlsx", "总积分榜.xlsx", []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜"}}, + {"/api/export/all.xlsx", "赛事成绩总表.xlsx", []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜", "田径·100米跑·男子"}}, + {"/api/export/events/1", "分项成绩表.xlsx", []string{"田径·100米跑·男子"}}, + {"/api/export/teams.xlsx", "单位积分总明细.xlsx", []string{"积分总明细"}}, + {"/api/export/teams/1", "单位积分明细.xlsx", []string{"第一分公司积分明细"}}, + } + for _, tc := range cases { + status, header, body := env.getRaw(t, client, tc.path) + if status != http.StatusOK { + t.Errorf("%s 应 200,得到 %d %s", tc.path, status, body) + continue + } + wantCD := "attachment; filename*=UTF-8''" + url.PathEscape(tc.file) + if got := header.Get("Content-Disposition"); got != wantCD { + t.Errorf("%s 文件名 = %q,期望 %q", tc.path, got, wantCD) + } + f := openWorkbook(t, body) + if got := f.GetSheetList(); !equalSheetList(got, tc.sheets) { + t.Errorf("%s sheet 列表 = %v,期望 %v", tc.path, got, tc.sheets) + } + _ = f.Close() + } +} diff --git a/server/internal/httpapi/export_handlers.go b/server/internal/httpapi/export_handlers.go index 35207e7..909468a 100644 --- a/server/internal/httpapi/export_handlers.go +++ b/server/internal/httpapi/export_handlers.go @@ -26,8 +26,11 @@ const maxProjectImportBytes = 512 << 20 func (s *server) exportRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/export/standings.xlsx", s.read(auth.RoleGuest, s.exportStandingsXLSX)) mux.HandleFunc("GET /api/export/all.xlsx", s.read(auth.RoleGuest, s.exportAllXLSX)) + mux.HandleFunc("GET /api/export/all-flat.xlsx", s.read(auth.RoleGuest, s.exportAllFlatXLSX)) // Go 1.22 ServeMux 的通配段必须独占一段,所以分项/单位导出用 /{id},文件名由响应头给出。 mux.HandleFunc("GET /api/export/events/{id}", s.read(auth.RoleGuest, s.exportEventXLSX)) + // teams.xlsx 与 teams/{id} 段数不同,ServeMux 不会冲突。 + mux.HandleFunc("GET /api/export/teams.xlsx", s.read(auth.RoleGuest, s.exportAllTeamsXLSX)) mux.HandleFunc("GET /api/export/teams/{id}", s.read(auth.RoleGuest, s.exportTeamXLSX)) mux.HandleFunc("GET /api/export/project.zip", s.read(auth.RoleAdmin, s.exportProjectZip)) @@ -79,6 +82,29 @@ func (s *server) exportAllXLSX(w http.ResponseWriter, r *http.Request) (any, err }) } +// exportAllFlatXLSX 导出全部成绩的单页平铺版:一个工作簿只含一张表, +// 每行 = 某个单位某个人的某个项目。与「全部」(all.xlsx,多 sheet)内容同源。 +func (s *server) exportAllFlatXLSX(w http.ResponseWriter, r *http.Request) (any, error) { + wb, err := export.LoadFlatWorkbook(s.ev, export.Options{}) + if err != nil { + return nil, internal(err) + } + return s.streamDownload(w, "赛事成绩总表(单页).xlsx", xlsxMIME, func(out io.Writer) error { + return export.WriteFlatXLSX(out, wb) + }) +} + +// exportAllTeamsXLSX 导出所有单位的积分明细:第一张总表 + 每个单位一张分表。 +func (s *server) exportAllTeamsXLSX(w http.ResponseWriter, r *http.Request) (any, error) { + wb, err := export.LoadAllTeamDetailsWorkbook(s.ev, export.Options{}) + if err != nil { + return nil, internal(err) + } + return s.streamDownload(w, "单位积分总明细.xlsx", xlsxMIME, func(out io.Writer) error { + return export.WriteAllTeamDetailsXLSX(out, wb) + }) +} + func (s *server) exportTeamXLSX(w http.ResponseWriter, r *http.Request) (any, error) { id, err := pathID(r, "id") if err != nil { diff --git a/server/internal/httpapi/export_teams_test.go b/server/internal/httpapi/export_teams_test.go new file mode 100644 index 0000000..3202ba7 --- /dev/null +++ b/server/internal/httpapi/export_teams_test.go @@ -0,0 +1,152 @@ +package httpapi_test + +import ( + "bytes" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/xuri/excelize/v2" +) + +const xlsxContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + +// getRaw 取原始响应体:xlsx 是二进制,不能走会把响应体按 JSON 解析的 do()。 +func (e *testEnv) getRaw(t *testing.T, client *http.Client, path string) (int, http.Header, []byte) { + t.Helper() + resp, err := client.Get(e.server.URL + path) + if err != nil { + t.Fatalf("请求 %s: %v", path, err) + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("读取 %s 响应: %v", path, err) + } + return resp.StatusCode, resp.Header, body +} + +func openWorkbook(t *testing.T, body []byte) *excelize.File { + t.Helper() + f, err := excelize.OpenReader(bytes.NewReader(body)) + if err != nil { + t.Fatalf("解析 xlsx: %v", err) + } + return f +} + +// 未登录(guest)能下载总明细,文件名与 sheet 结构符合约定;顺带确认 teams/{id} 没被改坏。 +func TestExportAllTeamDetailsXLSXGuest(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.exec(t, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, source_type, source_id, points) + VALUES (?, ?, ?, ?, 'result', 1, 10)`, ids.teamA, ids.athleteA, ids.event, ids.entryA) + env.exec(t, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, source_type, source_id, points) + VALUES (?, ?, ?, ?, 'result', 2, 8)`, ids.teamB, ids.athB, ids.event, ids.entryB) + // 已「彻底删除」(假删除的隐藏单位)且无流水:不能出现在导出件的任何一张表里。 + env.exec(t, `INSERT INTO teams (code, name, short_name, sort_order, active, deleted_at) + VALUES ('HID', '被隐藏单位', '隐藏', 9, 0, datetime('now'))`) + + // 不带登录态 = guest,与 standings.xlsx 同权限。 + client, _ := env.client() + status, header, body := env.getRaw(t, client, "/api/export/teams.xlsx") + if status != http.StatusOK { + t.Fatalf("guest 下载总明细应 200,得到 %d %s", status, body) + } + if got := header.Get("Content-Type"); got != xlsxContentType { + t.Errorf("Content-Type = %q", got) + } + wantCD := "attachment; filename*=UTF-8''" + url.PathEscape("单位积分总明细.xlsx") + if got := header.Get("Content-Disposition"); got != wantCD { + t.Errorf("Content-Disposition = %q,期望 %q", got, wantCD) + } + + f := openWorkbook(t, body) + defer func() { _ = f.Close() }() + want := []string{"积分总明细", "第一分公司积分明细", "第二分公司积分明细"} + if got := f.GetSheetList(); !equalSheetList(got, want) { + t.Fatalf("sheet 列表 = %v,期望 %v", got, want) + } + // 无流水的隐藏单位不得以任何形式出现在工作簿里(不在 sheet 名列表里就是全部证据)。 + for _, name := range f.GetSheetList() { + if strings.Contains(name, "隐藏") { + t.Errorf("无流水的隐藏单位不应出分表,却看到 %q", name) + } + } + master, err := f.GetRows("积分总明细") + if err != nil { + t.Fatalf("读取总表: %v", err) + } + if len(master) != 7 { // 4 行版式 + 2 条明细 + 合计 + t.Fatalf("总表行数 = %d,期望 7", len(master)) + } + if got := masterCell(master, 3, 0); got != "单位" { + t.Errorf("总表表头首列 = %q", got) + } + if got := masterCell(master, 6, 9); got != "18" { + t.Errorf("总表合计 = %q,期望 18", got) + } + + // 既有 teams/{id} 导出:权限、文件名、内容都不能被这次改动带坏。 + status, header, singleBody := env.getRaw(t, client, "/api/export/teams/1") + if status != http.StatusOK { + t.Fatalf("guest 下载单位明细应 200,得到 %d %s", status, singleBody) + } + wantSingle := "attachment; filename*=UTF-8''" + url.PathEscape("单位积分明细.xlsx") + if got := header.Get("Content-Disposition"); got != wantSingle { + t.Errorf("单位明细文件名 = %q,期望 %q", got, wantSingle) + } + sf := openWorkbook(t, singleBody) + defer func() { _ = sf.Close() }() + if got := sf.GetSheetList(); !equalSheetList(got, []string{"第一分公司积分明细"}) { + t.Fatalf("单位明细 sheet 列表 = %v", got) + } + + // 两个端点出的同一单位数字必须一致。 + singleRows, err := sf.GetRows("第一分公司积分明细") + if err != nil { + t.Fatalf("读取单位明细: %v", err) + } + allRows, err := f.GetRows("第一分公司积分明细") + if err != nil { + t.Fatalf("读取总明细分表: %v", err) + } + if len(singleRows) != len(allRows) { + t.Fatalf("行数不一致:单位明细 %d,总明细 %d", len(singleRows), len(allRows)) + } + for r := range singleRows { + for c := 0; c < 10; c++ { + if got, want := masterCell(allRows, r, c), masterCell(singleRows, r, c); got != want { + t.Errorf("第 %d 行第 %d 列不一致:总明细 %q,单位明细 %q", r, c, got, want) + } + } + } + // 折算口径也一致:10 分(个人流水,系数默认 1)。 + if got := masterCell(singleRows, 4, 8); got != "10" { + t.Errorf("单位明细首行积分 = %q,期望 10", got) + } +} + +func masterCell(rows [][]string, r, c int) string { + if r < 0 || r >= len(rows) { + return "" + } + if c < 0 || c >= len(rows[r]) { + return "" + } + return rows[r][c] +} + +func equalSheetList(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/server/internal/httpapi/handlers_results.go b/server/internal/httpapi/handlers_results.go index d1d70eb..4af7142 100644 --- a/server/internal/httpapi/handlers_results.go +++ b/server/internal/httpapi/handlers_results.go @@ -214,6 +214,13 @@ func announceFromPayload(payload any) (string, int) { return text, int(seconds) } +// displayCommandAudit 是 display.command 的审计载荷:指令内容原样保留(现场按 action 检索的 +// 既有查询不受影响),另外记上定向目标与实际命中的实例数。 +type displayCommandAudit struct { + displayCommandInput + Matched int `json:"matched"` +} + func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) (any, error) { var in displayCommandInput if err := decodeJSON(r, &in); err != nil { @@ -222,12 +229,19 @@ func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) ( if in.Action == "" { return nil, badRequest("action 必填", nil) } + matched := 0 // 清空公告必须落库,即使实时层尚不认这个 action(转发会被 NewDisplayCommand 拒收), // 否则「撤销公告」会整体失败;协议登记后这里的转发自动生效。 if s.display != nil && (in.Action != cmdAnnounceClear || realtime.ValidDisplayAction(in.Action)) { - if err := s.display.SendDisplayCommand(in.Action, in.Payload); err != nil { + n, err := s.display.SendDisplayCommand(in.Action, in.Payload, in.Targets) + if err != nil { return nil, internal(err) } + matched = n + // 点了名却一块屏都没命中(已离线或 id 写错)时明确报错,别让现场以为指令发出去了。 + if len(in.Targets) > 0 && n == 0 { + return nil, notFound("目标大屏已离线,请刷新在线列表") + } } // 公告要落库:只转发不落库的话,全屏推送结束(seconds 秒)后底栏会回落到「暂无公告」, // 新连上的大屏也拿不到当前公告。 @@ -244,8 +258,9 @@ func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) ( } } // 审计沿用同一条 action:现场按 action=display.command 检索的既有查询不受影响, - // after 里已经带了这次的具体 action 与公告正文。 - s.audit(r.Context(), "display.command", "display", 0, nil, in) + // after 里带了这次的具体 action、公告正文、定向目标与命中数。 + s.audit(r.Context(), "display.command", "display", 0, nil, + displayCommandAudit{displayCommandInput: in, Matched: matched}) return nil, nil } diff --git a/server/internal/httpapi/handlers_results_test.go b/server/internal/httpapi/handlers_results_test.go index 13a3e29..efe4f0d 100644 --- a/server/internal/httpapi/handlers_results_test.go +++ b/server/internal/httpapi/handlers_results_test.go @@ -9,14 +9,27 @@ import ( ) // strictDisplaySender 与 main.go 的 displaySender 同口径:经实时层校验 action,未知 action 直接报错。 -type strictDisplaySender struct{ sent []string } +// matched 为 nil 时按「广播算命中一块屏、定向一条算命中一块」返回,便于断言正常路径; +// 需要模拟「目标已离线」时把它设成 0。 +type strictDisplaySender struct { + sent []string + targets [][]string + matched *int +} -func (d *strictDisplaySender) SendDisplayCommand(action string, payload any) error { +func (d *strictDisplaySender) SendDisplayCommand(action string, payload any, targets []string) (int, error) { if _, err := realtime.NewDisplayCommand(action, payload); err != nil { - return err + return 0, err } d.sent = append(d.sent, action) - return nil + d.targets = append(d.targets, targets) + if d.matched != nil { + return *d.matched, nil + } + if len(targets) == 0 { + return 1, nil + } + return len(targets), nil } // settingValue 读取 settings 表里的点号键。 diff --git a/server/internal/httpapi/payload.go b/server/internal/httpapi/payload.go index 11ac343..6270a37 100644 --- a/server/internal/httpapi/payload.go +++ b/server/internal/httpapi/payload.go @@ -56,10 +56,14 @@ type recordBreakInput struct { RequestID string `json:"requestId"` } +// displayCommandInput 是大屏控制指令的请求体。 type displayCommandInput struct { - Action string `json:"action"` - Payload any `json:"payload"` - RequestID string `json:"requestId"` + Action string `json:"action"` + Payload any `json:"payload"` + // Targets 是要定向控制的大屏实例 id(GET /api/display/instances 的 id); + // 留空表示广播给所有大屏——公告这类全屏指令走的就是广播。 + Targets []string `json:"targets"` + RequestID string `json:"requestId"` } type voidAdjustmentPayload struct { diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 302de6c..7905dde 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -18,7 +18,9 @@ import ( // DisplaySender 把大屏控制命令推给实时层;未接入时命令只记审计、不影响接口可用性。 type DisplaySender interface { - SendDisplayCommand(action string, payload any) error + // SendDisplayCommand 下发一条大屏指令。targets 为空表示广播给所有大屏(公告靠它), + // 否则只发给这些实例 id(GET /api/display/instances 里的 id),并返回命中的实例数。 + SendDisplayCommand(action string, payload any, targets []string) (matched int, err error) } // Options 是构建路由所需的依赖。 diff --git a/server/internal/realtime/hub.go b/server/internal/realtime/hub.go index 3d1549e..f952f6c 100644 --- a/server/internal/realtime/hub.go +++ b/server/internal/realtime/hub.go @@ -3,8 +3,10 @@ package realtime import ( "context" "database/sql" + "fmt" "log/slog" "net/http" + "strings" "sync" "time" @@ -390,7 +392,7 @@ func (h *Hub) handleHello(ctx context.Context, c *client, p helloPayload) { if id == "" { id = p.DisplayKey } - h.registerDisplay(c, id, p.Page) + h.registerDisplay(c, id, p.Page, p.Screen) h.join(c, RoomDisplay) } @@ -528,7 +530,7 @@ func (h *Hub) SendCommandTo(room string, cmd DisplayCommand) { return } if cmd.Action == CmdGoto && (room == "" || room == RoomDisplay) { - h.setInstancePage(pageFromPayload(cmd.Payload)) + h.setInstancePage(pageFromPayload(cmd.Payload), nil) } h.sendMu.Lock() @@ -544,6 +546,62 @@ func (h *Hub) SendCommandTo(room string, cmd DisplayCommand) { } } +// SendCommandToInstances 按实例定向下发 display:command,返回命中的实例数。 +// +// targets 是要控制的 instanceID(GET /api/display/instances 里的 id);为空时退化成广播, +// 公告这类全屏指令靠的就是广播,这条语义不能改。已离线的实例不计入命中, +// 调用方据此告诉现场「那块屏没收到」。非法 action 返回错误而不是静默丢弃, +// 定向指令需要一个明确的失败信号。 +func (h *Hub) SendCommandToInstances(targets []string, cmd DisplayCommand) (int, error) { + if !ValidDisplayAction(cmd.Action) { + return 0, fmt.Errorf("未知的大屏指令 %q", cmd.Action) + } + if len(targets) == 0 { + h.SendCommand(cmd) + return h.onlineDisplayCount(), nil + } + + wanted := make(map[string]bool, len(targets)) + for _, id := range targets { + if id = strings.TrimSpace(id); id != "" { + wanted[id] = true + } + } + + h.sendMu.Lock() + matched := make(map[string]bool, len(wanted)) + for _, c := range h.roomClients(RoomDisplay) { + c.mu.Lock() + greeted, id := c.greeted, c.instanceID + c.mu.Unlock() + if !greeted || !wanted[id] { + continue + } + c.sendEvent(EventDisplayCommand, cmd) + matched[id] = true + } + h.sendMu.Unlock() + + if cmd.Action == CmdGoto { + h.setInstancePage(pageFromPayload(cmd.Payload), targets) + } + return len(matched), nil +} + +// onlineDisplayCount 统计已握手且在线的实例数,用于告知调用方广播命中了几块屏。 +func (h *Hub) onlineDisplayCount() int { + seen := make(map[string]bool) + for _, c := range h.roomClients(RoomDisplay) { + c.mu.Lock() + greeted, id := c.greeted, c.instanceID + c.mu.Unlock() + if greeted && id != "" { + seen[id] = true + } + } + return len(seen) +} + // checkSlow 用 ack 版本差识别慢客户端;超阈值直接断开,由客户端重连后补齐。 func (h *Hub) checkSlow(c *client, version int64) { lag := h.slowLagValue() diff --git a/server/internal/realtime/hub_test.go b/server/internal/realtime/hub_test.go index 9796185..ecde520 100644 --- a/server/internal/realtime/hub_test.go +++ b/server/internal/realtime/hub_test.go @@ -749,3 +749,162 @@ func TestGuardLoopRecoversPanic(t *testing.T) { t.Fatalf("panic 后关闭码应为 %d,得到 %d", websocket.StatusInternalError, c.closeCode) } } + +// ---------- 按实例定向下发 ---------- + +// instancePage 取注册表里某个实例的当前页;实例不存在返回空串。 +func instancePage(insts []Instance, id string) string { + for _, st := range insts { + if st.ID == id { + return st.Page + } + } + return "" +} + +// 定向下发只发给被点名的在线实例;注册表里的「当前页」也只更新那一块屏。 +func TestSendCommandTargetsSingleInstance(t *testing.T) { + env := newTestEnv(t) + a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-a", Page: "standings", Screen: "1"}) + b := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-b", Page: "standings", Screen: "2"}) + a.recvEvent(EventSnapshot) + b.recvEvent(EventSnapshot) + + cmd, err := NewDisplayCommand(CmdGoto, map[string]string{"page": "medals"}) + if err != nil { + t.Fatalf("构造 goto: %v", err) + } + matched, err := env.hub.SendCommandToInstances([]string{"screen-a"}, cmd) + if err != nil { + t.Fatalf("定向下发: %v", err) + } + if matched != 1 { + t.Fatalf("命中实例数应为 1,得到 %d", matched) + } + if got := decode[DisplayCommand](t, a.recvEvent(EventDisplayCommand)); got.Action != CmdGoto { + t.Fatalf("被点名的实例应收到 goto,得到 %s", got.Action) + } + // 没被点名的实例一条都不能收到(心跳之外的静默是这条断言的全部意义)。 + if ev, _, ok := b.recvWithin(200 * time.Millisecond); ok { + t.Fatalf("未被点名的实例不该收到消息,得到 %s", ev) + } + + insts := env.hub.Instances() + if got := instancePage(insts, "screen-a"); got != "medals" { + t.Fatalf("被点名实例的当前页应更新为 medals,得到 %q", got) + } + if got := instancePage(insts, "screen-b"); got != "standings" { + t.Fatalf("未被点名实例的当前页不该变,得到 %q", got) + } +} + +// targets 为空保持广播语义:所有在线实例都收到。 +func TestSendCommandEmptyTargetsBroadcasts(t *testing.T) { + env := newTestEnv(t) + a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-a"}) + b := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-b"}) + a.recvEvent(EventSnapshot) + b.recvEvent(EventSnapshot) + + cmd, err := NewDisplayCommand(CmdLock, map[string]any{}) + if err != nil { + t.Fatalf("构造 lock: %v", err) + } + matched, err := env.hub.SendCommandToInstances(nil, cmd) + if err != nil { + t.Fatalf("广播: %v", err) + } + if matched != 2 { + t.Fatalf("广播应命中 2 个在线实例,得到 %d", matched) + } + for _, c := range []*wsClient{a, b} { + if got := decode[DisplayCommand](t, c.recvEvent(EventDisplayCommand)); got.Action != CmdLock { + t.Fatalf("广播时每个实例都应收到 lock,得到 %s", got.Action) + } + } +} + +// 目标全都不在线(已断开或 id 不存在):命中 0,且没有任何连接收到指令。 +func TestSendCommandTargetsOfflineInstances(t *testing.T) { + env := newTestEnv(t) + a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-a"}) + a.recvEvent(EventSnapshot) + a.close() + waitFor(t, "实例离线", func() bool { + insts := env.hub.Instances() + return len(insts) == 1 && !insts[0].Online + }) + + cmd, err := NewDisplayCommand(CmdReload, map[string]any{}) + if err != nil { + t.Fatalf("构造 reload: %v", err) + } + matched, err := env.hub.SendCommandToInstances([]string{"screen-a", "never-seen"}, cmd) + if err != nil { + t.Fatalf("定向下发: %v", err) + } + if matched != 0 { + t.Fatalf("离线/不存在的实例不该计入命中,得到 %d", matched) + } +} + +// 目标里混了在线与离线:在线的收到,命中数只算在线的。 +func TestSendCommandTargetsMixedOnlineOffline(t *testing.T) { + env := newTestEnv(t) + a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-a"}) + b := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-b"}) + a.recvEvent(EventSnapshot) + b.recvEvent(EventSnapshot) + b.close() + waitFor(t, "screen-b 离线", func() bool { + for _, st := range env.hub.Instances() { + if st.ID == "screen-b" { + return !st.Online + } + } + return false + }) + + cmd, err := NewDisplayCommand(CmdGoto, map[string]string{"page": "results"}) + if err != nil { + t.Fatalf("构造 goto: %v", err) + } + matched, err := env.hub.SendCommandToInstances([]string{"screen-a", "screen-b"}, cmd) + if err != nil { + t.Fatalf("定向下发: %v", err) + } + if matched != 1 { + t.Fatalf("只应在线的 screen-a 计入命中,得到 %d", matched) + } + if got := decode[DisplayCommand](t, a.recvEvent(EventDisplayCommand)); got.Action != CmdGoto { + t.Fatalf("在线的实例应收到 goto,得到 %s", got.Action) + } +} + +// 非法 action 走定向通道要报错,不能静默丢掉。 +func TestSendCommandTargetsRejectsUnknownAction(t *testing.T) { + env := newTestEnv(t) + if _, err := env.hub.SendCommandToInstances([]string{"screen-a"}, DisplayCommand{Action: "nope"}); err == nil { + t.Fatal("非法 action 应报错") + } +} + +// 屏号上报:Console 窗口带 screen,局域网浏览器不带(空串),两者都要能查出来。 +func TestDisplayInstancesReportScreen(t *testing.T) { + env := newTestEnv(t) + console := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "console-1", Page: "standings", Screen: "2"}) + lan := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "lan-1", Page: "medals"}) + console.recvEvent(EventSnapshot) + lan.recvEvent(EventSnapshot) + + byID := make(map[string]Instance) + for _, st := range env.hub.Instances() { + byID[st.ID] = st + } + if got := byID["console-1"].Screen; got != "2" { + t.Fatalf("Console 窗口的屏号应为 2,得到 %q", got) + } + if got := byID["lan-1"].Screen; got != "" { + t.Fatalf("局域网浏览器不带屏号,应为空串,得到 %q", got) + } +} diff --git a/server/internal/realtime/instances.go b/server/internal/realtime/instances.go index f1f6786..788fb37 100644 --- a/server/internal/realtime/instances.go +++ b/server/internal/realtime/instances.go @@ -25,12 +25,17 @@ type Instance struct { RemoteAddr string `json:"remoteAddr,omitempty"` AckVersion int64 `json:"ackVersion"` Slow bool `json:"slow"` + // Screen 是 Console 分配的屏号(URL 里的 screen=), + // 管理端靠它把「本机显示器」与「在线实例」对上;局域网浏览器直接开 /display 时为空串。 + // 用字符串而不是数字:显示器的 id 由 Electron 给,不保证是小的正整数。 + Screen string `json:"screen"` } // instanceState 是注册表的内部记录;同一 instanceId 的多条连接合并成一个实例。 type instanceState struct { id string page string + screen string connectedAt time.Time lastHeartbeat time.Time remoteAddr string @@ -41,7 +46,7 @@ type instanceState struct { // registerDisplay 把连接挂到大屏实例上;未带 instanceId 时由服务端生成, // 保证「刷新页面 = 同一实例」之外的连接也能被登记和查询。 -func (h *Hub) registerDisplay(c *client, id, page string) string { +func (h *Hub) registerDisplay(c *client, id, page, screen string) string { if id == "" { id = "display-" + randomID() } @@ -71,6 +76,9 @@ func (h *Hub) registerDisplay(c *client, id, page string) string { if page != "" { st.page = page } + if screen != "" { + st.screen = screen + } st.lastHeartbeat = now st.remoteAddr = c.remoteAddr return id @@ -122,16 +130,30 @@ func (h *Hub) touchInstance(c *client, now time.Time) { } // setInstancePage 记录服务端刚下发的目标页,让注册表里的「当前页」跟得上 goto 指令。 -func (h *Hub) setInstancePage(page string) { +// targets 为空表示广播(所有在线实例),否则只更新被点名的那几个实例——定向切页后 +// 每块屏的「当前页」必须各算各的。 +func (h *Hub) setInstancePage(page string, targets []string) { if page == "" { return } + var picked map[string]bool + if len(targets) > 0 { + picked = make(map[string]bool, len(targets)) + for _, id := range targets { + picked[id] = true + } + } + h.mu.Lock() defer h.mu.Unlock() for _, st := range h.instances { - if st.conns > 0 { - st.page = page + if st.conns == 0 { + continue } + if picked != nil && !picked[st.id] { + continue + } + st.page = page } } @@ -178,6 +200,7 @@ func (h *Hub) Instances() []Instance { RemoteAddr: st.remoteAddr, AckVersion: st.ackVersion, Slow: st.slow, + Screen: st.screen, }) } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) diff --git a/server/internal/realtime/protocol.go b/server/internal/realtime/protocol.go index 6e58d33..cc40699 100644 --- a/server/internal/realtime/protocol.go +++ b/server/internal/realtime/protocol.go @@ -90,14 +90,16 @@ func encode(event string, data any) ([]byte, error) { // // role 只是「申请」:服务端以会话身份为准,未登录连接申请 admin 会被拒绝, // 因此这里没有 userId —— 定向房间的 userId 只能来自会话。 -// instanceId / page 是协议字段之外的可选扩展:注册表要记「谁在线、在哪一页」, -// 老客户端不带这两个字段也能正常工作。 +// instanceId / page / screen 是协议字段之外的可选扩展:注册表要记「谁在线、在哪一页、哪块屏」, +// 老客户端不带这几个字段也能正常工作。 type helloPayload struct { Role string `json:"role"` LastVersion *int64 `json:"lastVersion,omitempty"` DisplayKey string `json:"displayKey,omitempty"` InstanceID string `json:"instanceId,omitempty"` Page string `json:"page,omitempty"` + // Screen 是 Console 从 URL 带下来的屏号(screen=),局域网浏览器留空。 + Screen string `json:"screen,omitempty"` } // ackPayload 是 C→S 的 ack 载荷;服务端据此识别慢客户端。 diff --git a/server/main.go b/server/main.go index bd74596..d9ac4f2 100644 --- a/server/main.go +++ b/server/main.go @@ -187,13 +187,12 @@ func initialAdminBanner(username, password string) string { // displaySender 把 httpapi 的大屏命令接口适配到实时层。 type displaySender struct{ hub *realtime.Hub } -func (d displaySender) SendDisplayCommand(action string, payload any) error { +func (d displaySender) SendDisplayCommand(action string, payload any, targets []string) (int, error) { cmd, err := realtime.NewDisplayCommand(action, payload) if err != nil { - return err + return 0, err } - d.hub.SendCommand(cmd) - return nil + return d.hub.SendCommandToInstances(targets, cmd) } // 赛事项目名的来源,只用于启动日志:现场据此确认这次打开的是哪一个赛事。 diff --git a/web/src/admin/api/client.ts b/web/src/admin/api/client.ts index 18bc856..6bac819 100644 --- a/web/src/admin/api/client.ts +++ b/web/src/admin/api/client.ts @@ -1,6 +1,6 @@ import { ref } from 'vue' import { ApiError, createApiClient } from '@/shared/api' -import type { AthletePurgeResult } from '@/shared/api' +import type { AthletePurgeResult, DisplayInstance } from '@/shared/api' import type { AthleteStanding, AuditLog, @@ -186,6 +186,18 @@ export const adminApi = { displayCommand: (command: DisplayCommand): Promise => callWrite(() => client.display.command(command)), + /** + * 在线大屏实例(含 TTL 内保留的离线实例)。 + * + * 后端连不上时给空列表而不是报错:这是每几秒轮询一次的看板,弹一屏错误提示只会盖住 + * 真正有用的信息,页面自己会标出「示例数据 / 未就绪」。 + */ + displayInstances: (): Promise => + call( + () => client.display.instances(), + () => [], + ), + login: (username: string, password: string): Promise => callWrite(() => client.auth.login(username, password)), diff --git a/web/src/admin/components/system/systemApi.ts b/web/src/admin/components/system/systemApi.ts index 23d600d..3cdbeeb 100644 --- a/web/src/admin/components/system/systemApi.ts +++ b/web/src/admin/components/system/systemApi.ts @@ -516,7 +516,7 @@ export const backupsApi = { // 结果导出(xlsx) // --------------------------------------------------------------------------- -export type ResultExportKind = 'standings' | 'all' | 'event' | 'team' +export type ResultExportKind = 'standings' | 'all' | 'allFlat' | 'event' | 'team' | 'teamAll' export interface ResultExportRequest { kind: ResultExportKind @@ -532,8 +532,10 @@ export interface DownloadFile { const RESULT_EXPORT_FILENAME: Record = { standings: '总积分榜.xlsx', all: '赛事成绩总表.xlsx', + allFlat: '赛事成绩总表(单页).xlsx', event: '分项成绩表.xlsx', team: '单位积分明细.xlsx', + teamAll: '单位积分总明细.xlsx', } function mockDownload(request: ResultExportRequest, context: string): DownloadFile { @@ -558,6 +560,7 @@ export const resultsExportApi = { async () => { let path = '/export/standings.xlsx' if (request.kind === 'all') path = '/export/all.xlsx' + if (request.kind === 'allFlat') path = '/export/all-flat.xlsx' if (request.kind === 'event') { if (request.eventId === undefined) { throw new ApiError('VALIDATION_FAILED', '请先选择要导出的小项', 0) @@ -570,6 +573,8 @@ export const resultsExportApi = { } path = `/export/teams/${request.teamId}` } + // 总明细:服务端把「单位」列加在最前,再给每个有明细行的单位附一张分表。 + if (request.kind === 'teamAll') path = '/export/teams.xlsx' const blob = await requestBlob(path, { timeoutMs: 60_000 }) return { blob, filename: RESULT_EXPORT_FILENAME[request.kind] } }, diff --git a/web/src/admin/pages/DisplayPage.vue b/web/src/admin/pages/DisplayPage.vue index f7dda7f..cd1c702 100644 --- a/web/src/admin/pages/DisplayPage.vue +++ b/web/src/admin/pages/DisplayPage.vue @@ -1,6 +1,8 @@