Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55051fd633 | ||
|
|
c5ce1878fc | ||
|
|
d11cab31db | ||
|
|
99c91f99f7 | ||
|
|
7a3e4ad365 | ||
|
|
9663d5bfb0 | ||
|
|
830a4dbdb7 | ||
|
|
b631418a85 | ||
|
|
1b8a37ff8c | ||
|
|
b74768b7d5 | ||
|
|
2888200045 | ||
|
|
a57df78f99 | ||
|
|
9951a69938 | ||
|
|
b1c6d5d54a | ||
|
|
f09cb07a23 | ||
|
|
23ba8a1830 | ||
|
|
67eadd7fe6 | ||
|
|
a1aeed3c55 | ||
|
|
9fee6f2da5 | ||
|
|
877ff15823 | ||
|
|
78b14efaa9 | ||
|
|
c97d3150c9 | ||
|
|
9ef68c2589 | ||
|
|
ed2c0b3f3c | ||
|
|
6841cf49aa | ||
|
|
db94d9c119 | ||
|
|
8a27f5a4fb | ||
|
|
ab7662b7f1 | ||
|
|
45edfbeb71 | ||
|
|
53a0cb225c | ||
|
|
631c8dd8e1 | ||
|
|
8dd599bd97 | ||
|
|
a52e6a4a3b | ||
|
|
b4bdaef9ba | ||
|
|
44353b2c4c | ||
|
|
2c983bcfdf | ||
|
|
23fee5b818 | ||
|
|
0485975cd1 | ||
|
|
08a5740fb2 | ||
|
|
e5e0944d26 | ||
|
|
8e707e1e9b | ||
|
|
782a38f82f | ||
|
|
793d2ac064 | ||
|
|
5ae49b001c | ||
|
|
9d4ee0946c | ||
|
|
22ab49e73a | ||
|
|
fc96d17660 | ||
|
|
9c6d0bd3b3 | ||
|
|
6bd7bd1627 | ||
|
|
592e47aef7 | ||
|
|
b5f40642ff | ||
|
|
cded54e440 | ||
|
|
8c74a82da4 | ||
|
|
6f47849e1e | ||
|
|
407abec3b0 | ||
|
|
2d207fd654 | ||
|
|
005b51b2ec | ||
|
|
6483acf9ad | ||
|
|
24fff8dd08 | ||
|
|
c9d5b8ea89 | ||
|
|
7531ea29e1 | ||
|
|
c2696e768e | ||
|
|
0c9a6b6a27 | ||
|
|
1b78c38bb8 | ||
|
|
7c59dba9b9 | ||
|
|
a5941be9c4 | ||
|
|
27bddefaad | ||
|
|
045b0f5d95 | ||
|
|
a0a4824dda | ||
|
|
8039da384d | ||
|
|
e7948088df | ||
|
|
13f1f03cd0 | ||
|
|
96cf181ae3 | ||
|
|
2c9ff65fc4 | ||
|
|
4d56c73ae6 | ||
|
|
33d4b39eb9 | ||
|
|
52edecfa4c | ||
|
|
d50f5d7524 | ||
|
|
fc3361f766 | ||
|
|
d5b45761d7 | ||
|
|
c3d2c1416f | ||
|
|
1399bae59e | ||
|
|
de4bd63594 | ||
|
|
7f9701c465 | ||
|
|
8dae63ee70 | ||
|
|
b80c64a115 | ||
|
|
c121111f51 | ||
|
|
7f183d5327 | ||
|
|
1bc439d8e1 | ||
|
|
2c4547d210 | ||
|
|
2f75c31509 | ||
|
|
ad3770cfc9 | ||
|
|
74e48bf5bc | ||
|
|
76e725ca96 | ||
|
|
d288e2bb22 | ||
|
|
5ae55b8057 | ||
|
|
72462792ec | ||
|
|
f3813171d3 | ||
|
|
e86ca24d0e | ||
|
|
7243de93ce | ||
|
|
1af62a690e | ||
|
|
29162f5c1d | ||
|
|
8b792496d6 | ||
|
|
68ee99a5a2 | ||
|
|
ab2f4d1cec | ||
|
|
4cbf5a74f0 | ||
|
|
ad2322d558 | ||
|
|
e47251658b | ||
|
|
c92b404f6e | ||
|
|
bc8b1ee6c0 | ||
|
|
d0e3b93e76 | ||
|
|
0d32903b1a | ||
|
|
b62bb022bd | ||
|
|
7d5d94f815 |
No files matched your search
@@ -0,0 +1,22 @@
|
||||
# Windows 批处理必须是 CRLF:cmd.exe 在纯 LF 下可能解析不了标签(:label)与多行 if 块
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
|
||||
# 其余文本统一 LF,避免跨平台 diff 噪声
|
||||
*.go text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.vue text eol=lf
|
||||
*.css text eol=lf
|
||||
*.html text eol=lf
|
||||
*.json text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.md text eol=lf
|
||||
*.nsh text eol=crlf
|
||||
|
||||
# 二进制资源不做行尾转换
|
||||
*.png binary
|
||||
*.ico binary
|
||||
*.woff2 binary
|
||||
*.zip binary
|
||||
*.exe binary
|
||||
@@ -1,5 +1,6 @@
|
||||
# 构建产物
|
||||
dist/
|
||||
debug/
|
||||
*.exe
|
||||
*.test
|
||||
|
||||
@@ -11,6 +12,14 @@ data/
|
||||
rules/*
|
||||
!rules/README.md
|
||||
|
||||
# 内部署名(构建期注入,绝不进仓库)
|
||||
deploy/signature.local.txt
|
||||
deploy/signature.build.txt
|
||||
# 版本注入脚本(构建时由 deploy/prepare-extra.mjs 从仓库根 VERSION 生成)
|
||||
deploy/version.gen.bat
|
||||
# 安装器自定义脚本:许可页署名 + 上次安装目录记忆 + 防火墙入站规则(构建时生成)
|
||||
console/resources/installer-custom.nsh
|
||||
|
||||
# 开发文档(内部设计文档,不进仓库)
|
||||
devdocs/
|
||||
|
||||
@@ -29,6 +38,11 @@ logs/
|
||||
# 前端
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
console/node_modules/
|
||||
|
||||
# 前端构建产物(构建时生成,随 Go embed 内嵌)
|
||||
server/internal/webui/assets/*
|
||||
!server/internal/webui/assets/.gitkeep
|
||||
|
||||
# 编辑器 / 系统
|
||||
.vscode/*
|
||||
|
||||
@@ -14,26 +14,47 @@
|
||||
- **可追溯、可撤销**:草稿不上屏;发布后纠错走「撤销发布 → 改 → 重新发布」,全程留痕
|
||||
- **变更历史**:仿 Git 图谱,一眼看清成绩是怎么变成现在这样的
|
||||
- **现场公开**:积分明细可下钻,争议时一键推送到大屏
|
||||
- **全屏公告**:推送后先全屏显示(5–120 秒),随即常驻大屏底部公告栏,直到替换或清除
|
||||
- **分批发布**:一个项目分多场跑(如跑道 8 条、30 人分 4 场)时,**每跑完一批就能先发布一批**——空成绩只是提示、**不再挡发布**,未录的行自动跳过,补录后再发一次即自动重算;名次与积分先按已发布的那部分算,大屏立刻有内容
|
||||
- **实时赛况大屏页**:新增「实时赛况」页,显示当前进行中项目的实时排名(名次 / 运动员·队 / 单位 / 成绩);1 个项目单栏、2 个自动左右分栏;当前项目按「人工指定 → 排期命中(开始时间前后 30 分钟)→ 最近发布过成绩的项目」判定,**下一场开始前持续显示上一场**;可在「大屏控制 → 实时赛况」卡片配置
|
||||
- **显式批次层**:组别之下可再分批(`第 1 批`、`第 2 批`…),录入页按批筛选、只在自己这一批里找人;支持手动勾选设置批次、报名表可选列 `heat`、以及按人数「一键均分」。**批次只是录入 / 展示分组,不参与算分**——名次与积分仍按「小项 + 组别」计算
|
||||
- **成绩框实时提示**:聚焦成绩格打字时旁边浮出小气泡——合法显示解析结果(`1:06` → `= 66 秒`)、可疑 / 不合法直接说原因并带出那一格原文;不挡输入、不改行高,与发布前防呆同一套口径
|
||||
- **窄屏 / 手机可用**:管理面板适配窄窗口与手机浏览器(局域网访问 `/admin`):侧栏收成抽屉、宽表格横向滑动、对话框不超出屏幕、触控目标放大
|
||||
- **小项类型齐全**:个人 / 团体 / 对抗比赛 / **个人+团体**(同一个项目同时按个人与团体通道记分)
|
||||
- **多显示器**:支持多屏各锁一页(kiosk 全屏),也能在管理端为指定显示器单独开窗或收窗;「大屏控制」页的「在线大屏」列表实时显示每个在线实例(含局域网浏览器打开的 `/display`)当前在哪一页,可逐屏 / 逐设备单独切页
|
||||
- **规则可配置**:积分规则以规则包(YAML)描述,可导入导出、跨赛事复用
|
||||
- **赛事独立存档**:每个赛事一个独立项目目录,可整包导出/导入
|
||||
- **结果导出**:Excel 总表与分项可单独导出
|
||||
- **运动员名册**:启用 / 停用 / **彻底删除**三档——「停用」保留全部历史数据、随时可启用;「彻底删除」把人员档案连同他名下的报名、成绩与积分流水一起删掉且不可恢复,并自动按新排序重算受影响小项的排名与积分(大屏同步更新)
|
||||
- **参赛单位名册**:启用 / 停用 / **彻底删除**三档——「停用」保留全部历史数据、随时可启用;「彻底删除」是**假删除**,只把单位从列表里移除(界面上没有恢复入口),它的报名、成绩、积分流水与名下运动员全部保留,**积分榜不会变化**;报名表里再次用到该单位编码会让它自动恢复
|
||||
- **赛事独立存档**:每个赛事一个独立项目目录,整包可导出/导入(含会标、背景图、单位 logo 与上传的字体),并可在管理端页内一键切换当前赛事
|
||||
- **结果导出**:Excel 总表、全部(多 sheet)、**全部(单页)**、分项成绩表、单位积分明细可分别导出;「积分榜」页三个榜单的卡片头各有自己的明细导出——单位总分榜**导出总明细**(总表 + 每个有数据的单位一张分表)、团体奖牌榜**导出奖牌明细**(逐枚奖牌列出来源)、个人积分榜**导出个人明细**(逐条得分来源,与个人榜一致且不受折算系数影响),展开单位还能**导出该单位明细**
|
||||
- **日志可复盘**:服务端在数据目录 `logs\` 下按「放行 / 拦截」两类分目录落盘——`operations\` 记放行的写操作与常规运行日志,`interceptions\` 记我们主动拦下的报错与服务端异常;拦截日志带中文原因与**拦截点 `文件:行 函数名`**、操作人与 requestId,两类都按天 + 按大小轮转并自动清理旧文件(见部署指南第 10 节)
|
||||
- **成绩写法容错**:时间类接受 `66`、`66.33`、`1:06`、`1:06.33`、`1分06秒`、`1小时2分3秒` 等写法(全角冒号 / 全角数字也认),秒位 ≥ 60 会被拒但报错写明原因;成绩格里的示例是带「例」字的淡色斜体灰字,不会和已填内容混淆
|
||||
- **单位 logo**:**大屏端**的积分榜、奖牌榜、分项成绩表与个人榜都会显示参赛单位 logo(个人榜显示在姓名前;管理端页面只负责上传与配置,不显示 logo)
|
||||
- **大屏字体可配**:标题字体与正文字体可在「系统设置 → 赛事信息 → 字体」里从四款内置字体中选,或上传自己的字体(ttf / otf / woff / woff2,单个不超过 20 MiB);只作用于大屏,加载失败自动回落默认字体,字体文件随赛事项目包一起导出/导入
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 解压部署包
|
||||
2. 双击 `scoreboard.exe`(Linux 下执行 `./scoreboard`)
|
||||
2. 双击 `Scoreboard_Console.exe`(现场推荐:守护服务端、把大屏投到扩展屏、防熄屏、开机自启)
|
||||
3. 浏览器打开终端提示的地址
|
||||
|
||||
```
|
||||
大屏 http://<主机IP>:8080/display
|
||||
管理面板 http://<主机IP>:8080/admin
|
||||
大屏 http://<主机IP>:19540/display
|
||||
管理面板 http://<主机IP>:19540/admin
|
||||
```
|
||||
|
||||
> 也可以直接双击 `scoreboard.exe`(Linux 下执行 `./scoreboard`)只跑服务端,大屏投放与进程守护需自行接管。
|
||||
>
|
||||
> 完全离线运行,不依赖外网,不需要安装任何运行时。
|
||||
|
||||
## 文档
|
||||
|
||||
- 使用说明、操作手册:随正式版发布补充
|
||||
- [升级说明](./docs/升级说明.md):**每次升级前先看**——这一版改了什么、升级后要补做什么、分数/奖牌为什么变化、5 分钟核对清单
|
||||
- [上线检查清单](./docs/上线检查清单.md):比赛当天照着走一遍的核对表(含交付物、目标机、首次启动、赛事项目包与规则包导入、大屏与公告、备份与回退)
|
||||
- [部署指南](./docs/部署指南.md):运行环境、安装与首次启动、端口与配置、大屏接线、赛事项目包导入/切换、备份恢复、故障排查
|
||||
- [使用手册](./docs/使用手册.md):角色权限、各功能页面的操作步骤、大屏端使用、常见操作问题
|
||||
- [报名名单导入规范](./docs/报名名单导入规范.md):报名表该用什么格式、11 列逐列怎么填、个人/团体/「个人+团体」三种项目的填法、常见报错对照(可直接发给各单位填表人)
|
||||
- [常见问题(FAQ)](./docs/常见问题.md):账号、成绩与积分、大屏、数据备份、导出导入
|
||||
- 规则包格式:见 [rules/README.md](./rules/README.md)
|
||||
|
||||
## 许可证
|
||||
|
||||
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,24 @@
|
||||
# 服务端启动配置
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 19540
|
||||
# WebSocket 跨站 Origin 白名单(可选,path.Match 模式);同源与不带 Origin 的客户端始终放行
|
||||
allowedOrigins: []
|
||||
|
||||
# 运行时数据目录(数据库 / 备份 / 上传资源)
|
||||
dataDir: data
|
||||
|
||||
# 自动备份:按间隔把 system.db 与赛事库快照到 dataDir/backups
|
||||
backup:
|
||||
intervalMinutes: 5 # 备份间隔(分钟);<=0 关闭自动备份
|
||||
keep: 20 # 每个库滚动保留的份数,超出的旧备份自动删除
|
||||
|
||||
log:
|
||||
level: info # debug | info | warn | error(作用于控制台与 operations 放行类日志)
|
||||
# 日志根目录,其下分两类:
|
||||
# operations/ 放行的操作 + 常规运行日志(按上面的 level 过滤)
|
||||
# interceptions/ 我们主动拦截的报错 + 服务端异常(固定按 debug 采集,便于复盘"为什么被拦/在哪一行拦的")
|
||||
# 留空 = <数据目录>/logs
|
||||
dir: ""
|
||||
maxSizeMB: 20 # 单个日志文件大小上限(MB);<=0 用内置默认
|
||||
keepFiles: 10 # 每类保留的份数(含当前文件);<=0 用内置默认
|
||||
@@ -0,0 +1,3 @@
|
||||
# deploy/build.bat 在打包前把 docs/使用说明.txt 暂存到这里(extraFiles 指向该目录)
|
||||
extra/*
|
||||
!extra/.gitkeep
|
||||
@@ -0,0 +1,209 @@
|
||||
# Scoreboard Console
|
||||
|
||||
赛事积分榜系统的桌面宿主(Electron)。它只做六件事:
|
||||
|
||||
1. 拉起并守护同目录(或配置指定)的 `scoreboard.exe`,把它的 stdout/stderr 接管到管理窗口与日志文件;
|
||||
2. 用 `screen.getAllDisplays()` 枚举显示器,把大屏页面投到扩展屏;
|
||||
3. `powerSaveBlocker.start('prevent-display-sleep')` 防熄屏;
|
||||
4. 提供启动/停止/状态/日志的管理 GUI 和托盘菜单;
|
||||
5. 默认注册登录项但不启用(不随系统启动),可在服务管理窗口的「开机自启」开关里开启;
|
||||
6. 在服务管理窗口展示服务端在线的大屏实例(`GET /api/display/instances`)。
|
||||
|
||||
规范来源:`devdocs/06-大屏端设计.md` 第 6 节、`devdocs/10-部署与现场方案.md` 第 4 节。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
console/
|
||||
├── package.json # main 指向 main/index.js;electron-builder 配置
|
||||
├── config.json # 运行配置模板(打包后首次启动复制到用户数据目录,改这里的只在开发态生效)
|
||||
├── main/
|
||||
│ ├── index.js # 应用入口:窗口 / 托盘 / IPC / 生命周期
|
||||
│ ├── config.js # 配置加载与合并、exe 路径解析
|
||||
│ ├── datadir.js # 数据目录解析 + 旧数据一次性迁移
|
||||
│ ├── datadir.test.js # 数据目录的单元测试
|
||||
│ ├── userpaths.js # 配置/规则目录解析 + 首次复制迁移(config.json / config.yaml / rules)
|
||||
│ ├── userpaths.test.js # 配置/规则路径的单元测试
|
||||
│ ├── service.js # 服务端子进程守护:spawn / 日志转发 / 健康检查 / 自动重启
|
||||
│ ├── service.test.js # 守护逻辑的单元测试(注入假 spawn/时钟/HTTP)
|
||||
│ ├── instances.js # 已连接大屏实例看板:复用管理面板登录态读 /api/display/instances
|
||||
│ ├── account.js # 管理员账号重置:参数校验 + 调 scoreboard.exe passwd
|
||||
│ ├── displays.js # 大屏窗口管理:选屏、kiosk、排布、24h 重载
|
||||
│ ├── displays.test.js # 大屏窗口模式与 URL 一致性的回归测试
|
||||
│ ├── log-bus.js # 日志环形缓冲 + 落文件 + 广播
|
||||
│ └── tray-icon.js # 托盘图标(base64 内嵌,无外部资源文件)
|
||||
├── scripts/
|
||||
│ └── check-syntax.js # pnpm check:跨平台递归 node --check
|
||||
├── extra/ # 打包前由 deploy/prepare-extra.mjs 暂存 使用说明.txt 与 报名名单导入规范.txt(extraFiles 指向它)
|
||||
└── renderer/
|
||||
├── index.html # 管理窗口(含自绘标题栏)
|
||||
├── styles.css
|
||||
├── app.js
|
||||
├── preload.js # 管理窗口的 contextBridge 白名单
|
||||
├── display-preload.js # 大屏窗口的 window.displayShell 白名单(窗口级全屏状态,供页面收放自绘标题栏)
|
||||
└── shell-preload.js # 服务端管理面板窗口的 window.desktopShell 白名单(窗口控制 + 显示器菜单)
|
||||
```
|
||||
|
||||
## 运行方式
|
||||
|
||||
```bash
|
||||
cd console
|
||||
pnpm install # 首次需要下载 Electron
|
||||
pnpm start # 等价于 electron .
|
||||
```
|
||||
|
||||
开发时把 `scoreboard.exe` 放在以下任一位置都能被找到(`config.json` 里的相对路径会按顺序探测):
|
||||
|
||||
1. `console/`
|
||||
2. 当前工作目录
|
||||
3. 仓库根目录
|
||||
4. `dist/`
|
||||
|
||||
现场(部署包)形态:
|
||||
|
||||
```
|
||||
scoreboard-windows-amd64/
|
||||
├── Scoreboard_Console.exe
|
||||
├── resources/app.asar # 内含 main/ renderer/ config.json
|
||||
├── config.json # exe 旁边的配置模板(首次启动复制到用户数据目录)
|
||||
├── scoreboard.exe
|
||||
├── config.yaml # 服务端配置模板(同上)
|
||||
├── rules/ # 规则包模板(同上)
|
||||
└── logs/console.log # 运行时生成(日志仍留在安装目录)
|
||||
```
|
||||
|
||||
数据、配置与规则都不在安装目录里,都在 `%LOCALAPPDATA%\ScoreboardConsole\` 下(见下文两节)。
|
||||
|
||||
打包后 `config.json` 读取顺序:`SCOREBOARD_CONSOLE_CONFIG` 环境变量 → 用户数据目录 `<用户根>\console\config.json`(首次启动从 exe 旁边的模板复制)。开发态(`pnpm start`)三者都不接管,仍用仓库里的 `console/config.json`。
|
||||
|
||||
## 数据目录(重要)
|
||||
|
||||
electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖安装会先静默跑一遍旧版卸载器**,所以装在安装目录里的 `data\` 会被整体删掉;`%APPDATA%\Scoreboard Console` 卸载时同样会删,因此也不能用 `app.getPath('userData')`。最终方案:
|
||||
|
||||
| 场景 | 数据目录 |
|
||||
| --- | --- |
|
||||
| 打包态,`server.dataDir` 留空(默认) | `%LOCALAPPDATA%\ScoreboardConsole\data`(卸载器不碰这个位置) |
|
||||
| 配置了 `server.dataDir` | 配什么用什么:绝对路径直接用;相对路径以安装目录为基准 |
|
||||
| 开发态(`pnpm start`),`server.dataDir` 留空 | 不接管,沿用服务端默认(工作目录下的 `data`),仓库里的 `data/` 不受影响 |
|
||||
|
||||
- Console 把最终目录通过服务端的 `-data <绝对路径>` 传下去,`passwd` 子命令带同一个参数,保证改的是同一个库里的账号;
|
||||
- 迁移只在**打包态**做一次:**目标目录为空(或不存在)且 `<安装目录>\data` 有数据**时,用 `fs.renameSync` 把旧目录整体改名过去(同盘秒完成)。跨盘、权限不足等任何失败都只记日志,旧数据原地保留;
|
||||
- **「没有旧数据」与「读不到旧目录」分开处理**:旧目录不存在/存在但空才是 `no-legacy`(不写日志);旧目录读不出来(EACCES、ENOTDIR 等)返回 `legacy-unreadable` 并写一条「读不到旧数据目录」警告,绝不当成「没有旧数据」静默跳过(回归测试见 `main/datadir.test.js`);
|
||||
- 两边都有数据时**不做任何动作**,只写一条醒目日志提示人工确认,两份都不会被自动删除;
|
||||
- 迁移结果与最终数据目录都写进 `logs/console.log`(搜「数据目录」「已把旧数据从」);
|
||||
- 数据目录若仍在安装目录内,启动时会额外打一条警告。
|
||||
|
||||
改完 `datadir.js` 跑 `node --test main/datadir.test.js`(Node 内置测试运行器,无额外依赖)。
|
||||
|
||||
## 配置与规则目录(重要)
|
||||
|
||||
同样因为卸载器会删掉整个安装目录,随包的 `config.json`、`config.yaml`、`rules\` 也不放在安装目录里,而是跟数据目录同一基座:
|
||||
|
||||
| 内容 | 打包态(`planUserPaths` mode=`user`) | 开发态(mode=`install`) |
|
||||
| --- | --- | --- |
|
||||
| Console 配置 | `<用户根>\console\config.json` | 仓库 `console/config.json` |
|
||||
| 服务端配置 | `<用户根>\config.yaml` | 仓库 `config.yaml` 的位置不变 |
|
||||
| 规则目录 | `<用户根>\rules\` | 仓库 `rules/` |
|
||||
|
||||
(`<用户根>` = `%LOCALAPPDATA%\ScoreboardConsole`,与数据目录同一处。)
|
||||
|
||||
- `userpaths.js` 只做三件事:解析这三个落点、首次复制、失败回落;`planUserPaths` 是纯函数(只算路径),`prepareUserPaths` 才碰磁盘,便于单测;
|
||||
- **首次运行复制**:目标不存在且安装目录里有那份模板 → `fs.copyFileSync` / `fs.cpSync`(规则目录先拷到 `<target>.tmp-<pid>` 再整体 rename,失败不会留下半份)。安装目录那份**留作模板**,不做移动;
|
||||
- **已存在就绝不覆盖**:用户目录里已经有的那份一律优先,既不覆盖也不删除(用户改过的配置、自己加的规则包都不会被模板顶掉);
|
||||
- 拉起服务端时 `-config` 的值被换成 `<用户根>\config.yaml` 的绝对路径(`withConfigArg`:参数里有 `-config` 就改值,没有就补一条),`passwd` 用同一份;这样服务端读的配置不再取决于 cwd;
|
||||
- 任一项目标目录建不出来或不可写时只记日志并**回落到安装目录里那份**(`serverConfig` 回落时返回 `null`,拉起服务端时沿用 `server.args` 原值),绝不抛异常、绝不删用户文件;
|
||||
- 拿不到 `%LOCALAPPDATA%` 时三项全回落安装目录并记一条警告;
|
||||
- 日志文件 `logs/console.log` 是个例外:仍然相对**部署包根目录**(打包态 = 安装目录,开发态 = `console/`)解析,不跟着配置搬(易失的排查材料,搬了反而与文档、现场习惯不一致)。
|
||||
|
||||
改完 `userpaths.js` 跑 `node --test main/*.test.js`。
|
||||
|
||||
## 行为说明
|
||||
|
||||
| 场景 | 行为 |
|
||||
| --- | --- |
|
||||
| 只有一块屏 | 窗口化(屏幕 80% 大小、居中),不做多屏编排,**不跟随** `display-metrics-changed`(分辨率/缩放变化不重排,行为冻结);`display-added` / `display-removed` 一直听着,电视插回来会自动排布回 kiosk。监听常驻、只有 `closeAll()`(服务停止 / 应用退出)才摘(回归测试见 `main/displays.test.js`);`F11` 手动切换全屏,窗口级全屏状态经 `display-preload.js` 下发给页面,页面据此收起自绘标题栏。窗口化窗口**不置顶、不穿透**:F11 全屏完全由用户控制,置顶会把它变成一块收不掉、盖住一切的覆盖层 |
|
||||
| 模式与 URL | **必须永远一致**:模式(窗口化 / kiosk)记在窗口的 entry 上,谁改模式谁按新模式重载 URL。`layout()` 只对排布自己开的窗口套用全局 `single`,管理面板手动开的窗口保持自己的模式——否则会出现「窗口化窗口被改成 kiosk 全屏、URL 里还留着 `windowed=1`」这种页面认知与窗口真实模式脱钩、标题栏画反的问题(回归测试见 `main/displays.test.js`)|
|
||||
| 多块屏 | 除内置屏外的每块屏一个 kiosk 窗口,按 `display.bounds`(DIP)定位,光标由页面 CSS 隐藏。窗口**默认吞掉鼠标、不穿透**:`setIgnoreMouseEvents(true)` 的语义是「本窗口忽略鼠标」,点击会落到它后面的窗口或桌面上(现场想防的误触反而变成误点别的东西),被忽略鼠标的窗口也点不活、抢不回前台;只有确实要把大屏当纯展示层、同一块屏上还要操作别的窗口时才显式配 `display.allowClickThrough: true`。这个开关是从 `display.ignoreMouse` **改名**来的:旧版默认穿透,而且旧版模板已经把 `ignoreMouse: true` 写进了现场机器的用户配置(`userpaths.js` 不覆盖用户文件),只改默认值救不了老机器——换个新键名,旧键自然失效;启动时若发现配置里还留着旧键,会在 `logs/console.log` 里记一条提示(只记一次),说明它已废弃、现在默认不穿透、想穿透该写哪个键。窗口**置顶在 `screen-saver` 层级**(Windows 上 `setAlwaysOnTop(true)` 的默认层级 `floating` 仍画在任务栏下面,只有 pop-up-menu 及以上才压得住任务栏),所以别的窗口拖到这块屏上盖不住它、任务栏也盖不住它,被盖住后点一下就能回到最前。调用顺序**必须**是「先 `setBounds` 定位 → 再 `setKiosk(true)` → 最后置顶」:反过来会把窗口从真正的全屏态拽回「恰好铺满该屏的无边框窗口」,既不是 topmost、也压不住任务栏(回归测试见 `main/displays.test.js`)|
|
||||
| 选屏 | 运行时枚举,不写死坐标;`selection` 可选 `all-secondary`(默认,用 `display.internal` 排除内置屏)/ `all` / `specified` |
|
||||
| 页面锁定 | 每块屏各锁一页,URL 形如 `http://127.0.0.1:19540/display?page=standings&lock=1&screen=<id>&instance=<host>-<id>`,页序按 `display.pages` 轮转 |
|
||||
| 防熄屏 | Console 启动即生效,退出时释放;前台后台都算 |
|
||||
| 服务端就绪 | 首次 `/api/health` 通过后开大屏窗口;等待超过 `display.waitForServerMs` 也先开,避免现场只见桌面 |
|
||||
| 服务端崩溃 | 按 `restartDelayMs` **线性**退避(第 N 次等 N × `restartDelayMs`,最多 5 倍)自动重启(`maxRestarts=0` 为无限);持续健康 `healthyResetMs` 后崩溃计数清零;恢复健康后自动重载大屏 |
|
||||
| 服务端按设计退出 | 切换赛事 / 一键恢复时服务端**先回执 200 再自己优雅退出**(退出码 0)。这种退出**不当崩溃**:状态是「重启中」(黄灯,不是红灯)、崩溃计数不 +1、同样按 `restartDelayMs` 自动拉起。判定见 `service.js` 的 `isPlannedExit`:退出码 0 **且**落在预期退出窗口内(最后一次健康检查通过后最多 15 秒,HealthInterval 更大时按两个探测周期放宽)——刚起来就退 0 或卡死很久才退 0 都仍按崩溃处理 |
|
||||
| 数据目录 | 默认 `%LOCALAPPDATA%\ScoreboardConsole\data`(安装目录之外,覆盖安装/卸载都不删),启动时按 `-data <绝对路径>` 交给服务端;打包态首次启动做一次性迁移(见「数据目录」一节),开发态不接管 |
|
||||
| 配置与规则 | 同样在 `%LOCALAPPDATA%\ScoreboardConsole\`(`console\config.json`、`config.yaml`、`rules\`),打包态首次从安装目录复制、之后只用用户目录那份;拉起服务端时 `-config` 传绝对路径(见「配置与规则目录」一节) |
|
||||
| 停止服务 | 先 `POST server.shutdownPath` 并带 `X-Shutdown-Token`(启动时用 `crypto.randomBytes(32)` 生成,经环境变量 `SCOREBOARD_SHUTDOWN_TOKEN` 只交给该子进程)请求服务端优雅关闭;受理后最多等 `shutdownTimeoutMs`,超时或接口不可用(404/连接失败)再强杀 |
|
||||
| 已连接大屏实例 | 管理窗口打开时每 5 秒读一次服务端 `GET /api/display/instances`(复用管理面板的 `scoreboard_session` Cookie,需记录员及以上),显示实例数、页面名与在线状态;窗口隐藏即停表,未登录/服务未启动时显示占位文案 |
|
||||
| 关管理窗口 | 只隐藏到托盘,服务端继续运行;退出必须走托盘「退出」 |
|
||||
| 开机自启 | 默认「已注册但未启用」;在服务管理窗口的「开机自启」开关里控制(`startup.openAtLogin`),仅打包后的 Windows/macOS 生效,开发态不注册 |
|
||||
| 窗口外观 | 管理窗口与服务端管理面板窗口都是 `frame: false` 无边框,标题栏与最小化/最大化/关闭三键由页面自绘(拖拽区 `-webkit-app-region: drag`,按钮 `no-drag`,双击标题栏切最大化,关闭键 hover 变红) |
|
||||
| 服务端管理面板 | 用独立无边框 Electron 窗口加载 `http://127.0.0.1:<port>/admin`(`shell-preload.js` 暴露 `window.desktopShell`),不再调系统浏览器;面板页在浏览器里直接打开时标题栏自动不渲染 |
|
||||
| 显示器菜单 | 面板页的「大屏控制 → 显示器」卡片经 `display:list` / `display:open` / `display:close` / `display:relayout` 指挥本机开窗:按屏创建、聚焦、收窗。**全屏只在「多于一块屏 + 非主屏」时放行**,判定在主进程做,页面传 `fullscreen: true` 也会被降级为窗口化;面板页只拿到这几条白名单通道,拿不到 `ipcRenderer` |
|
||||
| 管理员账号 | 主进程 `spawn` 同目录 `scoreboard.exe passwd -config <同一份配置> -data <同一个数据目录> -user <当前用户名> -new-username <新用户名> -password <新密码>`(留空 = 不改 / 随机生成)。本机进程、不经 HTTP,因此免登录验证;用户名/密码含空格、制表符或换行会被主进程拒绝,密码不进日志。`passwd` 会作废该用户全部会话,UI 提交前二次确认并提示 |
|
||||
| 日志 | 内存环形缓冲(`log.maxLines`)+ 文件 `logs/console.log`(超 `maxFileBytes` 轮转一份 `.1`,轮转前会删掉旧 `.1` 以兼容 Windows);文件固定落在部署包根目录(打包态 = 安装目录),不随配置搬到用户数据目录;管理窗口按同一个 `log.maxLines` 截断(超上限从头部丢,长跑不变卡) |
|
||||
| 服务管理窗口的路径显示 | 「服务端程序」「配置文件」两行分别是解析出的 `scoreboard.exe` 与 Console 自己那份 `config.json` 的绝对路径(打包态后者在用户数据目录里,见上一节) |
|
||||
|
||||
## 配置项(`config.json`;打包态生效的是用户数据目录里那份)
|
||||
|
||||
| 键 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `server.exe` | `scoreboard.exe` | 相对路径会以部署包根目录(打包态 = exe 所在目录,开发态 = `console/`)、当前目录、上级目录、`dist\` 依次探测,也可写绝对路径 |
|
||||
| `server.args` | `["-config","config.yaml"]` | 传给服务端的参数;其中 `-config` 的值会被换成解析出的绝对路径(`<用户根>\config.yaml`),其余参数(如 `-project`)原样传 |
|
||||
| `server.dataDir` | `""` | 数据目录;空串 = 用默认位置(打包态 `%LOCALAPPDATA%\ScoreboardConsole\data`),填了就按它来(相对路径以部署包根目录为基准)。Console 会把它作为 `-data <绝对路径>` 追加到服务端参数后 |
|
||||
| `server.host` / `server.port` | `127.0.0.1` / `19540` | 健康检查、关闭握手与管理面板地址(`0.0.0.0` 会自动换成回环地址探测) |
|
||||
| `server.autoStart` | `true` | Console 启动后是否立刻拉起服务端 |
|
||||
| `server.healthIntervalMs` | `5000` | 健康检查间隔 |
|
||||
| `server.restartDelayMs` | `3000` | 重启基础退避:**第 N 次等 N × 本值**(×1..×5,线性,不是翻倍) |
|
||||
| `server.maxRestarts` | `0` | 0 = 无限重启 |
|
||||
| `server.shutdownPath` | `/api/shutdown` | 优雅关闭握手接口(需服务端实现并校验 `X-Shutdown-Token`),不可用时回退强杀 |
|
||||
| `server.shutdownTimeoutMs` | `5000` | 服务端受理关闭请求后等待其自行退出的上限,超过则强杀 |
|
||||
| `server.healthyResetMs` | `60000` | 连续健康多久后清零崩溃计数 |
|
||||
| `display.selection` | `all-secondary` | `all-secondary` / `all` / `specified` |
|
||||
| `display.displays` | `[]` | `specified` 时生效,填 display id 或 0 起序号 |
|
||||
| `display.pages` | `["standings","medals","individual","results"]` | 按屏序轮转锁定;只填大屏支持的页名 |
|
||||
| `display.pageParam` | `page` | 页面 URL 参数名 |
|
||||
| `display.kiosk` | `true` | 多屏时是否 kiosk(单屏恒窗口化) |
|
||||
| `display.allowClickThrough` | `false` | 多屏 kiosk 窗口是否**允许点击穿透**(`setIgnoreMouseEvents(true)`)。写 `true` 后该窗口忽略所有鼠标事件:点击落到它后面的窗口/桌面(现场误触会点到别的东西),大屏也点不活、被盖住后抢不回前台。只把大屏当纯展示层、且要在同一块屏上操作别的窗口时才开 |
|
||||
| `display.ignoreMouse` | — | **已废弃 → 见 `display.allowClickThrough`**。旧版默认 `true` 且被旧模板写进现场机器的用户配置,只改默认值救不了老机器,所以换了键名;本键现在**完全不读**,配置文件里留着它只会让启动日志多一条提示,不影响行为 |
|
||||
| `display.reloadEveryHours` | `24` | 定时重载兜底,0 = 关闭 |
|
||||
| `display.waitForServerMs` | `20000` | 服务端未就绪时开大屏的兜底等待 |
|
||||
| `display.backgroundColor` | `#0b0f1a` | 窗口底色,减少加载白闪 |
|
||||
| `startup.openAtLogin` | `false` | 默认注册但不启用(任务管理器可见、已禁用);在服务管理窗口「开机自启」开关里控制(开发态不注册) |
|
||||
| `admin.width` / `admin.height` | `1280` / `860` | 管理窗口初始尺寸(服务端管理面板窗口按 `width` 不小于 1100 取用) |
|
||||
| `log.maxLines` / `log.file` / `log.maxFileBytes` | `2000` / `logs/console.log` / `4194304` | 日志缓冲与文件;`log.file` 相对部署包根目录解析(不随配置搬到用户数据目录) |
|
||||
|
||||
配置缺项自动用内置默认值,文件不存在或 JSON 语法错误时回落到全默认值,并在管理窗口顶部给出提示。配置里如果还留着已改名的旧键(目前是 `display.ignoreMouse`),启动时会在 `logs/console.log` 里记一条中文提示(只在启动记一次,不随大屏重排重复刷),说明该键已废弃、现在默认不穿透、要穿透该写哪个键;旧键的值一律忽略,**不会**被自动改写进用户的配置文件。
|
||||
|
||||
## 打包(electron-builder)
|
||||
|
||||
`package.json` 里已写好 `build` 段:
|
||||
|
||||
```bash
|
||||
cd console
|
||||
pnpm install
|
||||
pnpm check # 跨平台语法检查(scripts/check-syntax.js)
|
||||
pnpm dist # electron-builder --win --x64 → ../dist/console/
|
||||
pnpm pack # 只出目录,不生成安装包,便于现场调试
|
||||
```
|
||||
|
||||
- 产物:NSIS 安装包 + zip,文件名形如 `Scoreboard Console-<版本>-x64.*`。`<版本>` 不是手写的:`deploy/prepare-extra.mjs` 在打包前读仓库根 `VERSION`(当前 `dev_0.1.8`)去掉 `dev_` 前缀写进 `console/package.json` 的 `version`,`artifactName` 用 `${productName}-${version}-${arch}.${ext}` 展开 —— 也就是说**这里不要手改版本号**,改仓库根的 `VERSION`。
|
||||
- `extraFiles` 会把 `../dist/scoreboard.exe`、`../config.yaml`、`config.json`、`../rules` 拷到安装目录 —— 这四样里的后三样(`config.json`、`config.yaml`、`rules/`)在打包态只当**模板**:首次启动时复制到用户数据目录,之后 Console 与 `backup.bat` 都只用用户目录那份(见「配置与规则目录」一节)。`config.json` 缺失时 Console 会回落到内置默认值。
|
||||
- 部署包根目录的说明书是可选的:`deploy/prepare-extra.mjs` 在打包前按一张清单暂存到 `console/extra/`(`extraFiles` 会把该目录内容平铺到安装目录根):`docs/使用手册.md` → `使用说明.txt`,`docs/报名名单导入规范.md` → `报名名单导入规范.txt`(各自优先取 `docs/` 下的同名 `.txt`)。缺哪个就在构建日志里注明并跳过。
|
||||
- 图标在 `resources/icon.ico`(`package.json` 的 `win.icon` 指向它)。
|
||||
- 主程序文件名由 `win.executableName` 决定,现在是 `Scoreboard_Console.exe`;`productName` 仍是 `Scoreboard Console`(显示名、`userData` 目录不变)。
|
||||
- `nsis.include` 指向 `resources/installer-custom.nsh`(构建时由 `deploy/prepare-extra.mjs` 生成,已 gitignore):许可页顶部文案 + 记住上次安装目录(重装时默认沿用,不退回系统默认路径)。
|
||||
- 已在 Windows 构建机上通过 `deploy\build.bat` 实跑(含 `pnpm dist`,产出 NSIS 安装包 + zip);Linux 开发机未安装 Electron/electron-builder。
|
||||
|
||||
## 尚未实机验证
|
||||
|
||||
以下内容只做了静态语法检查,需要在 Windows + 真实多显示器环境跑一遍:
|
||||
|
||||
- 多屏 kiosk 定位与 `display-metrics-changed` 重排(改分辨率 / 缩放 / 插拔 HDMI)。
|
||||
- kiosk 窗口的置顶(`setAlwaysOnTop(true, 'screen-saver')`)与任务栏覆盖:能否稳定压住任务栏、别的窗口拖到该屏上会不会盖住它、点击后能否回到最前——`screen-saver` 层级在 Windows 上的实际效果只能在真机上验;`setKiosk` / `setBounds` 的先后顺序同理(Linux 开发机没有 Electron 运行时)。
|
||||
- 管理面板显示器菜单:真正的 kiosk 全屏 / 窗口化结果、`screen.getAllDisplays()` 的 `label` 与主屏判定、`display:list` 返回的 scaleFactor 是否与现场一致。
|
||||
- `powerSaveBlocker` 在长时间后台下的实际效果。
|
||||
- 托盘图标在 Windows 任务栏的显示效果(16px 缩放)。
|
||||
- `scoreboard.exe` 真实拉起、日志转发、健康检查与崩溃重启的端到端链路。
|
||||
- 数据目录迁移实机路径:老版本安装目录里的 `data\` 是否被整体搬到 `%LOCALAPPDATA%\ScoreboardConsole\data`、跨盘安装(安装目录在 D 盘、`%LOCALAPPDATA%` 在 C 盘)时的失败日志与「旧数据原地保留」表现。
|
||||
- 配置/规则首次复制实机路径:老版本升级到本版本后,安装目录里的 `config.json` / `config.yaml` / `rules\` 是否被复制到 `%LOCALAPPDATA%\ScoreboardConsole\` 下对应位置、改过配置后覆盖安装是否仍然生效、`%LOCALAPPDATA%` 不可写(或被杀毒软件拦截)时是否只记日志并回落到安装目录里那份。
|
||||
- electron-builder 打包已通过 `deploy\build.bat` 实跑;NSIS 安装包在真机上的安装 / 卸载流程尚未验证。
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"server": {
|
||||
"exe": "scoreboard.exe",
|
||||
"args": ["-config", "config.yaml"],
|
||||
"dataDir": "",
|
||||
"host": "127.0.0.1",
|
||||
"port": 19540,
|
||||
"autoStart": true,
|
||||
"healthIntervalMs": 5000,
|
||||
"restartDelayMs": 3000,
|
||||
"maxRestarts": 0,
|
||||
"shutdownPath": "/api/shutdown",
|
||||
"shutdownTimeoutMs": 5000,
|
||||
"healthyResetMs": 60000
|
||||
},
|
||||
"display": {
|
||||
"selection": "all-secondary",
|
||||
"displays": [],
|
||||
"pages": ["standings", "medals", "individual", "results"],
|
||||
"pageParam": "page",
|
||||
"kiosk": true,
|
||||
"allowClickThrough": false,
|
||||
"reloadEveryHours": 24,
|
||||
"waitForServerMs": 20000,
|
||||
"backgroundColor": "#0b0f1a"
|
||||
},
|
||||
"startup": {
|
||||
"openAtLogin": false
|
||||
},
|
||||
"admin": {
|
||||
"width": 1280,
|
||||
"height": 860
|
||||
},
|
||||
"log": {
|
||||
"maxLines": 2000,
|
||||
"file": "logs/console.log",
|
||||
"maxFileBytes": 4194304
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""自绘安装器的位图素材生成器(构建期工具,产物随仓库一起提交)。
|
||||
|
||||
为什么要有这个脚本:安装器窗口是 NSIS 自绘的无边框窗口(760×480),界面本身就是一堆
|
||||
钉死在固定坐标上的位图,NSIS 只负责「把图贴到点上」。窗口尺寸、文案、配色每改一次就要
|
||||
重出一整套图,手工导图必然会出 1x/2x 不同步、半像素发虚、缺字形变方框这些问题,所以
|
||||
整套素材全部由代码生成。
|
||||
|
||||
用法:
|
||||
python3 console/installer/art-gen/gen_art.py
|
||||
|
||||
产出:
|
||||
console/installer/art/1x/ 常规 DPI 用
|
||||
console/installer/art/2x/ 4K@200% 缩放用(同一套常量乘 2)
|
||||
debug/安装界面预览/06-五页预览.png 人眼验收用的五页联络图(不参与打包)
|
||||
|
||||
改了设计就重跑本脚本,不要手改 art/ 里的位图:改图不改脚本,下次重跑就被覆盖,
|
||||
而且 1x/2x 会悄悄失去同步。
|
||||
|
||||
几条硬约定:
|
||||
- 所有坐标/尺寸都按 1x 设计稿书写,2x 由同一套常量乘 2 得到,不写第二套数值;
|
||||
- 「✕ / — / ✓ / 圆点」这类符号在 Noto Sans SC 里没有字形,一律用线段和圆画出来;
|
||||
- 字体只用 noto-sans/sc 目录下的简体字重,tc(繁体)目录缺简体字形,会把字渲染成方框。
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# ---------------------------------------------------------------- 路径
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__)) # console/installer/art-gen
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) # 仓库根
|
||||
ART_DIR = os.path.join(os.path.dirname(HERE), "art") # console/installer/art
|
||||
CONTACT_SHEET = os.path.join(REPO, "debug", "安装界面预览", "06-五页预览.png")
|
||||
|
||||
# 图标源文件放在仓库根,打包脚本和安装器素材共用同一份,避免出现两个版本的应用图标
|
||||
ICON_SRC = os.path.join(REPO, "Scoreboard.png")
|
||||
|
||||
# 字体不在仓库里(三套字重加起来 25MB,装进仓库不划算),所以按本机绝对路径引用;
|
||||
# 换机器构建时把这个常量改掉即可,字体缺失会在 font() 里直接报错而不是悄悄退化
|
||||
FONT_DIR = "/home/Aska/Fonts/noto-sans/sc"
|
||||
|
||||
|
||||
def repo_version():
|
||||
"""读仓库根 VERSION(版本号唯一真相),去掉 dev_ 前缀给安装包显示用。"""
|
||||
with open(os.path.join(REPO, "VERSION"), encoding="utf-8-sig") as fh:
|
||||
return (fh.readline().strip() or "0.0.0").removeprefix("dev_")
|
||||
|
||||
# ---------------------------------------------------------------- 配色
|
||||
|
||||
NAVY = "#0b1e3a" # 标题栏 / 品牌栏渐变起点
|
||||
NAVY_END = "#16406f" # 品牌栏渐变终点
|
||||
CONTENT_BG = "#f6f9fd" # 右侧内容区底色
|
||||
TXT = "#16233a" # 正文深色
|
||||
TXT_DIM = "#5a6b85" # 次要文字
|
||||
BRAND_WEAK = "#8ba6c6" # 品牌栏弱文字
|
||||
BRAND_WEAKER = "#6b86a8" # 品牌栏最弱(版本号)
|
||||
PRIMARY = "#2f9bf0" # 主色
|
||||
PRIMARY_HOVER = "#3fa9f5"
|
||||
PRIMARY_PRESSED = "#1f86d8"
|
||||
PRIMARY_SOFT = "#f0f6ff" # 次要按钮悬停底色
|
||||
PRIMARY_SOFT_DOWN = "#dce9fa" # 次要按钮按下底色
|
||||
CYAN = "#4fd8ff" # 品牌栏字标点缀
|
||||
BORDER = "#cfdcec"
|
||||
DIVIDER = "#e3ebf5"
|
||||
CLOSE_HOVER = "#e5484d"
|
||||
CLOSE_PRESSED = "#c93b40"
|
||||
SUCCESS = "#34c759"
|
||||
|
||||
TB_DIVIDER = "#1e3e69" # 标题栏底部分隔线
|
||||
TB_TEXT = "#cfe3ff"
|
||||
TB_MIN = "#96afcd"
|
||||
TB_MIN_HOVER = "#cfe3ff"
|
||||
TB_CLOSE = "#bed2eb"
|
||||
TB_HOVER = "#1b2d4a" # 白色 8% 叠在海军蓝上的等效色
|
||||
TB_HOVER_DOWN = "#2d3e56" # 白色 14% 叠在海军蓝上
|
||||
|
||||
# ---------------------------------------------------------------- 版面
|
||||
|
||||
WIN_W, WIN_H = 760, 480
|
||||
TITLE_H = 40
|
||||
SIDE_W = 240
|
||||
BRAND_H = WIN_H - TITLE_H
|
||||
|
||||
PAGE_X, PAGE_Y = SIDE_W, TITLE_H
|
||||
PAGE_W, PAGE_H = WIN_W - SIDE_W, WIN_H - TITLE_H
|
||||
PAD_L, PAD_T = 32, 28
|
||||
CONTENT_W = PAGE_W - PAD_L * 2 # 456
|
||||
DIVIDER_Y = 376 # 页面内按钮条的上边线
|
||||
|
||||
BTN_W, BTN_H = 108, 36
|
||||
BTN_Y = 416
|
||||
BTN_PRIMARY_X = 728 - BTN_W # 620,右边缘对齐内容区右边线
|
||||
BTN_SECONDARY_X = 608 - BTN_W # 500,与主按钮间距 12
|
||||
|
||||
TB_MIN_BOX = (672, 0, 44, 40)
|
||||
TB_CLOSE_BOX = (716, 0, 44, 40)
|
||||
|
||||
SS = 4 # 超采样倍数:PIL 画圆/圆角矩形没有抗锯齿,4 倍渲染再缩回来才有干净边缘
|
||||
|
||||
_font_cache = {}
|
||||
|
||||
|
||||
def font(weight, size, k):
|
||||
"""取字重字体;size 是 1x 设计字号,k 是当前渲染倍率(= 输出倍率 × 超采样倍数)。"""
|
||||
key = (weight, size, k)
|
||||
if key not in _font_cache:
|
||||
path = os.path.join(FONT_DIR, "NotoSansSC-%s.otf" % weight)
|
||||
if not os.path.exists(path):
|
||||
raise SystemExit("字体缺失:%s(本机请确认 noto-sans/sc 三个字重在位)" % path)
|
||||
_font_cache[key] = ImageFont.truetype(path, size * k)
|
||||
return _font_cache[key]
|
||||
|
||||
|
||||
def U(k):
|
||||
"""返回 1x 设计坐标 → 渲染像素的换算函数。
|
||||
|
||||
统一四舍五入:设计稿里存在 12.5、11.5 这类半像素字号和坐标,不取整会让
|
||||
线条落在半像素上,缩回来变成两行灰边。
|
||||
"""
|
||||
return lambda v: int(round(v * k))
|
||||
|
||||
|
||||
def down(im):
|
||||
"""超采样降采样,得到最终位图。"""
|
||||
return im.resize((im.width // SS, im.height // SS), Image.LANCZOS)
|
||||
|
||||
|
||||
def hgrad(w, h, c0, c1):
|
||||
"""横向线性渐变。品牌栏底色与品牌栏位图共用本函数,两者叠加才不会露出接缝。"""
|
||||
a, b = ImageColor_rgb(c0), ImageColor_rgb(c1)
|
||||
im = Image.new("RGB", (w, h))
|
||||
d = ImageDraw.Draw(im)
|
||||
for x in range(w):
|
||||
t = x / max(w - 1, 1)
|
||||
d.line([(x, 0), (x, h)], fill=tuple(round(a[i] + (b[i] - a[i]) * t) for i in range(3)))
|
||||
return im
|
||||
|
||||
|
||||
def ImageColor_rgb(s):
|
||||
s = s.lstrip("#")
|
||||
return tuple(int(s[i:i + 2], 16) for i in (0, 2, 4))
|
||||
|
||||
|
||||
def box(p, x, y, w, h):
|
||||
"""1x 的 (x, y, 宽, 高) → PIL 的闭区间 bbox(PIL 的右下角是含在内的)。"""
|
||||
return [p(x), p(y), p(x + w) - 1, p(y + h) - 1]
|
||||
|
||||
|
||||
def text_top(d, p, x, y, s, f, fill):
|
||||
"""按「字形可见顶部」定位。
|
||||
|
||||
中英文混排时 ascender 高度不一致(数字/英文比汉字矮),直接给 y 会让同一组的
|
||||
几行文字看起来忽高忽低,所以按实际墨迹框归位。
|
||||
"""
|
||||
b = d.textbbox((0, 0), s, font=f, anchor="la")
|
||||
d.text((p(x), p(y) - b[1]), s, font=f, fill=fill, anchor="la")
|
||||
|
||||
|
||||
def text_bottom(d, p, x, y, s, f, fill):
|
||||
"""按「字形可见底部」定位,用于版本号这种按底边距对齐的文字。"""
|
||||
b = d.textbbox((0, 0), s, font=f, anchor="la")
|
||||
d.text((p(x), p(y) - b[3]), s, font=f, fill=fill, anchor="la")
|
||||
|
||||
|
||||
def text_center(d, p, cx, cy, s, f, fill):
|
||||
"""按墨迹框居中,用于按钮文字。"""
|
||||
b = d.textbbox((0, 0), s, font=f)
|
||||
d.text((p(cx) - (b[2] + b[0]) / 2, p(cy) - (b[3] + b[1]) / 2), s, font=f, fill=fill)
|
||||
|
||||
|
||||
def dot(d, p, cx, cy, r, fill):
|
||||
"""画圆点。要点符号 fonts 里没有,只能画。"""
|
||||
d.ellipse([p(cx - r), p(cy - r), p(cx + r), p(cy + r)], fill=fill)
|
||||
|
||||
|
||||
def check(d, p, pts, color, width):
|
||||
"""对勾用折线画。✓ 字形在 Noto Sans SC 里缺,硬渲染会出方框。"""
|
||||
d.line([(p(x), p(y)) for x, y in pts], fill=color, width=p(width), joint="curve")
|
||||
|
||||
|
||||
def paste_icon(im, p, x, y, w, h):
|
||||
"""把应用图标等比缩放进 (x, y, w, h) 的方框并居中。
|
||||
|
||||
源图自带圆角透明背景,必须带 alpha 贴;另外先裁掉四周透明留白,否则同一张图
|
||||
在标题栏和品牌栏两处的视觉大小会对不上。
|
||||
"""
|
||||
src = Image.open(ICON_SRC).convert("RGBA")
|
||||
src = src.crop(src.getchannel("A").getbbox())
|
||||
tw, th = p(w), p(h)
|
||||
r = min(tw / src.width, th / src.height)
|
||||
nw, nh = max(1, int(round(src.width * r))), max(1, int(round(src.height * r)))
|
||||
src = src.resize((nw, nh), Image.LANCZOS)
|
||||
im.paste(src, (p(x) + (tw - nw) // 2, p(y) + (th - nh) // 2), src)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 背景与框架
|
||||
|
||||
def draw_base(k):
|
||||
"""底层:标题栏 + 品牌栏渐变 + 内容区底色。
|
||||
|
||||
品牌栏渐变在这里也要画一遍(上面还会贴 brand.bmp),这样底图本身就是完整的,
|
||||
单独看 base.bmp 也不会露出一块空白。
|
||||
"""
|
||||
p = U(k)
|
||||
im = Image.new("RGB", (p(WIN_W), p(WIN_H)), CONTENT_BG)
|
||||
im.paste(hgrad(p(SIDE_W), p(BRAND_H), NAVY, NAVY_END), (0, p(TITLE_H)))
|
||||
d = ImageDraw.Draw(im)
|
||||
d.rectangle(box(p, 0, 0, WIN_W, TITLE_H), fill=NAVY)
|
||||
return down(im)
|
||||
|
||||
|
||||
def draw_brand(k):
|
||||
"""品牌栏内容。底色是同一段渐变,贴在 base 的品牌栏上无缝。"""
|
||||
p = U(k)
|
||||
im = hgrad(p(SIDE_W), p(BRAND_H), NAVY, NAVY_END)
|
||||
d = ImageDraw.Draw(im)
|
||||
|
||||
# 图标 x 与下方文字的左边距(36)取齐,不留出那 12px 的错位
|
||||
paste_icon(im, p, 36, 48, 96, 96)
|
||||
|
||||
text_top(d, p, 36, 172, "赛事积分榜", font("Bold", 26, k), "#ffffff")
|
||||
text_top(d, p, 36, 219, "S C O R E B O A R D", font("Medium", 11, k), CYAN)
|
||||
text_top(d, p, 36, 250, "一拖二 · 实时积分大屏", font("Regular", 12, k), BRAND_WEAK)
|
||||
text_top(d, p, 36, 272, "裁判长管理面板", font("Regular", 12, k), BRAND_WEAK)
|
||||
# 版本号按「左边距 36 / 底边距 40」定位,底部对齐比顶部对齐更稳
|
||||
# 版本从仓库根 VERSION 读(唯一真相):烘焙进位图意味着发新版必须重跑本脚本,
|
||||
# deploy/prepare-extra.mjs 里有一道校验,版本对不上会直接让构建失败,不会悄悄发错版本
|
||||
text_bottom(d, p, 36, BRAND_H - 40, f"版本 v{repo_version()}", font("Regular", 11, k), BRAND_WEAKER)
|
||||
return down(im)
|
||||
|
||||
|
||||
def draw_titlebar(k):
|
||||
"""标题栏:图标 + 标题 + 右侧最小化/关闭。
|
||||
|
||||
两个按钮图形按 44×40 按钮框居中画,和 tb-*.bmp 的图形位置严格一致 —— 悬停时
|
||||
NSIS 用按钮位图盖住这块区域,位置不一致的话图形会跳一下。
|
||||
"""
|
||||
p = U(k)
|
||||
im = Image.new("RGB", (p(WIN_W), p(TITLE_H)), NAVY)
|
||||
d = ImageDraw.Draw(im)
|
||||
|
||||
paste_icon(im, p, 14, 10, 20, 20)
|
||||
text_top(d, p, 44, 12, "赛事积分榜 安装程序", font("Medium", 14, k), TB_TEXT)
|
||||
|
||||
min_cx = TB_MIN_BOX[0] + TB_MIN_BOX[2] / 2
|
||||
close_cx = TB_CLOSE_BOX[0] + TB_CLOSE_BOX[2] / 2
|
||||
d.line([(p(min_cx - 7), p(20)), (p(min_cx + 7), p(20))], fill=TB_MIN, width=p(2))
|
||||
d.line([(p(close_cx - 6), p(14)), (p(close_cx + 6), p(26))], fill=TB_CLOSE, width=p(2))
|
||||
d.line([(p(close_cx + 6), p(14)), (p(close_cx - 6), p(26))], fill=TB_CLOSE, width=p(2))
|
||||
|
||||
# 底部分隔线画在标题栏位图内部(画在 base 上会被标题栏位图盖掉)
|
||||
d.rectangle(box(p, 0, TITLE_H - 1, WIN_W, 1), fill=TB_DIVIDER)
|
||||
return down(im)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 五个页面
|
||||
|
||||
def page_base(k):
|
||||
"""页面公共底:内容区底色 + 按钮条上边线。"""
|
||||
p = U(k)
|
||||
im = Image.new("RGB", (p(PAGE_W), p(PAGE_H)), CONTENT_BG)
|
||||
d = ImageDraw.Draw(im)
|
||||
d.rectangle(box(p, 0, DIVIDER_Y, PAGE_W, 1), fill=DIVIDER)
|
||||
return im, d
|
||||
|
||||
|
||||
def page_head(d, p, k, title, desc=None):
|
||||
text_top(d, p, PAD_L, PAD_T, title, font("Bold", 20, k), TXT)
|
||||
if desc:
|
||||
text_top(d, p, PAD_L, 62, desc, font("Regular", 13, k), TXT_DIM)
|
||||
|
||||
|
||||
def draw_page_welcome(k):
|
||||
p = U(k)
|
||||
im, d = page_base(k)
|
||||
page_head(d, p, k, "欢迎安装", "本向导将把《赛事积分榜》安装到这台计算机。")
|
||||
|
||||
items = [
|
||||
"一拖二部署:一个服务端,同时带实时积分大屏与裁判长管理面板",
|
||||
"完全离线运行,不需要外网",
|
||||
"数据与赛事配置存在当前用户目录,覆盖安装不会丢",
|
||||
]
|
||||
f = font("Regular", 13, k)
|
||||
y = 118
|
||||
for it in items:
|
||||
b = d.textbbox((p(52), p(y)), it, font=f, anchor="la")
|
||||
# 圆点对齐每行墨迹的垂直中点,行内英文/数字混排时也不会跑偏
|
||||
dot(d, p, PAD_L + 4, (b[1] + b[3]) / 2 / k, 4, PRIMARY)
|
||||
d.text((p(52), p(y)), it, font=f, fill=TXT, anchor="la")
|
||||
y += 32
|
||||
return down(im)
|
||||
|
||||
|
||||
def draw_page_license(k):
|
||||
p = U(k)
|
||||
im, d = page_base(k)
|
||||
page_head(d, p, k, "许可协议", "请阅读以下条款。继续安装表示你接受这些条款。")
|
||||
|
||||
lines = [
|
||||
"本软件以 MIT 许可协议发布,你可以自由使用、修改和分发。",
|
||||
"分发或再发布时,必须保留原始的版权声明与许可声明。",
|
||||
"本软件按「现状」提供,不附带任何形式的明示或默示担保。",
|
||||
"因使用本软件造成的任何损失,作者不承担任何责任。",
|
||||
]
|
||||
f = font("Regular", 13, k)
|
||||
y = 104
|
||||
for ln in lines:
|
||||
text_top(d, p, PAD_L, y, ln, f, TXT)
|
||||
y += 22
|
||||
|
||||
# 只画外框,里面的协议正文由真控件渲染(位图放不下整篇协议)
|
||||
d.rectangle(box(p, PAD_L, 196, CONTENT_W, 176), outline=BORDER, width=p(1))
|
||||
return down(im)
|
||||
|
||||
|
||||
def draw_page_dir(k):
|
||||
p = U(k)
|
||||
im, d = page_base(k)
|
||||
page_head(d, p, k, "选择安装位置", "安装程序将把《赛事积分榜》装到下面的目录。")
|
||||
|
||||
# 这一条是「目录输入行」的外框:框内左侧跑真编辑框,右侧 96×34 的「浏览…」
|
||||
# 按钮位图直接压在外框右端(框高 34 与按钮等高,所以按钮正好卡在框内)
|
||||
d.rectangle(box(p, PAD_L, 110, CONTENT_W, 34), outline=BORDER, width=p(1))
|
||||
|
||||
text_top(d, p, PAD_L, 170, "安装模式", font("Medium", 13, k), TXT)
|
||||
# 文字从 56 起排,左边 24px 留给单选钮位图
|
||||
text_top(d, p, 56, 222, "仅为我安装(不需要管理员权限)", font("Regular", 12.5, k), TXT)
|
||||
text_top(d, p, 56, 262, "为所有用户安装(会弹 UAC,并自动放行防火墙 19540)",
|
||||
font("Regular", 12.5, k), TXT)
|
||||
return down(im)
|
||||
|
||||
|
||||
def draw_page_progress(k):
|
||||
p = U(k)
|
||||
im, d = page_base(k)
|
||||
# 进度页正文区留空:阶段文案和进度条都用独立位图贴上去(NSIS 换图=换状态)
|
||||
page_head(d, p, k, "正在安装")
|
||||
d.rounded_rectangle(box(p, PAD_L, 140, CONTENT_W, 8), radius=p(4), fill=DIVIDER)
|
||||
return down(im)
|
||||
|
||||
|
||||
def draw_page_finish(k):
|
||||
p = U(k)
|
||||
im, d = page_base(k)
|
||||
page_head(d, p, k, "安装完成", "《赛事积分榜》已安装到这台计算机。")
|
||||
|
||||
# 成功标记在内容区水平居中:绿实心圆 + 白对勾
|
||||
cx = PAGE_W / 2
|
||||
cy = 164
|
||||
d.ellipse([p(cx - 24), p(cy - 24), p(cx + 24), p(cy + 24)], fill=SUCCESS)
|
||||
check(d, p, [(cx - 11, cy + 0.5), (cx - 3, cy + 8.5), (cx + 12, cy - 8.5)], "#ffffff", 3.5)
|
||||
|
||||
# 勾选项文字从 56 起排,左边 24px 留给勾选框位图
|
||||
text_top(d, p, 56, 300, "立即运行 赛事积分榜", font("Regular", 13, k), TXT)
|
||||
text_top(d, p, PAD_L, 336, "安装位置:D:\\Program Files\\Scoreboard_Console",
|
||||
font("Regular", 11.5, k), TXT_DIM)
|
||||
return down(im)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 按钮
|
||||
|
||||
def draw_button(k, label, w, h, kind, state):
|
||||
"""圆角按钮三态。圆角外填内容区底色,贴到按钮条上才不会露出白角。"""
|
||||
p = U(k)
|
||||
im = Image.new("RGB", (p(w), p(h)), CONTENT_BG)
|
||||
d = ImageDraw.Draw(im)
|
||||
if kind == "primary":
|
||||
bg = {"normal": PRIMARY, "hover": PRIMARY_HOVER, "pressed": PRIMARY_PRESSED}[state]
|
||||
fg, border = "#ffffff", None
|
||||
else:
|
||||
bg = {"normal": "#ffffff", "hover": PRIMARY_SOFT, "pressed": PRIMARY_SOFT_DOWN}[state]
|
||||
border = BORDER if state == "normal" else PRIMARY
|
||||
fg = TXT
|
||||
d.rounded_rectangle([0, 0, p(w) - 1, p(h) - 1], radius=p(8), fill=bg,
|
||||
outline=border, width=p(1) if border else 0)
|
||||
text_center(d, p, w / 2, h / 2, label, font("Medium", 13, k), fg)
|
||||
return down(im)
|
||||
|
||||
|
||||
def draw_tb_button(k, kind, state):
|
||||
"""标题栏按钮。常态底色=标题栏底色,贴上去和标题栏融为一体。"""
|
||||
p = U(k)
|
||||
im = Image.new("RGB", (p(44), p(40)), NAVY)
|
||||
d = ImageDraw.Draw(im)
|
||||
if kind == "close":
|
||||
bg = {"normal": NAVY, "hover": CLOSE_HOVER, "pressed": CLOSE_PRESSED}[state]
|
||||
fg = "#ffffff" if state != "normal" else TB_CLOSE
|
||||
else:
|
||||
bg = {"normal": NAVY, "hover": TB_HOVER, "pressed": TB_HOVER_DOWN}[state]
|
||||
fg = {"normal": TB_MIN, "hover": TB_MIN_HOVER, "pressed": "#ffffff"}[state]
|
||||
d.rectangle(box(p, 0, 0, 44, 40), fill=bg)
|
||||
if kind == "min":
|
||||
d.line([(p(15), p(20)), (p(29), p(20))], fill=fg, width=p(2))
|
||||
else:
|
||||
d.line([(p(16), p(14)), (p(28), p(26))], fill=fg, width=p(2))
|
||||
d.line([(p(28), p(14)), (p(16), p(26))], fill=fg, width=p(2))
|
||||
return down(im)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 控件
|
||||
|
||||
def draw_check(k, on):
|
||||
p = U(k)
|
||||
im = Image.new("RGB", (p(16), p(16)), CONTENT_BG)
|
||||
d = ImageDraw.Draw(im)
|
||||
if on:
|
||||
d.rounded_rectangle(box(p, 0, 0, 16, 16), radius=p(4), fill=PRIMARY)
|
||||
check(d, p, [(4.5, 8.5), (7, 11), (11.5, 5.5)], "#ffffff", 2)
|
||||
else:
|
||||
d.rounded_rectangle(box(p, 0, 0, 16, 16), radius=p(4), fill="#ffffff",
|
||||
outline=BORDER, width=p(1))
|
||||
return down(im)
|
||||
|
||||
|
||||
def draw_radio(k, on):
|
||||
p = U(k)
|
||||
im = Image.new("RGB", (p(16), p(16)), CONTENT_BG)
|
||||
d = ImageDraw.Draw(im)
|
||||
if on:
|
||||
d.ellipse(box(p, 0, 0, 16, 16), fill="#ffffff", outline=PRIMARY, width=p(2))
|
||||
d.ellipse(box(p, 5, 5, 6, 6), fill=PRIMARY)
|
||||
else:
|
||||
d.ellipse(box(p, 0, 0, 16, 16), fill="#ffffff", outline=BORDER, width=p(1))
|
||||
return down(im)
|
||||
|
||||
|
||||
def draw_progress(k, step):
|
||||
"""进度条 0..5 档,20% 一格。
|
||||
|
||||
进度条底与凹槽位图尺寸完全一致,NSIS 直接叠在凹槽上即可;填充部分左端保留圆角、
|
||||
右端切齐,符合进度条「推进」的视觉。
|
||||
"""
|
||||
p = U(k)
|
||||
im = Image.new("RGB", (p(456), p(8)), CONTENT_BG)
|
||||
d = ImageDraw.Draw(im)
|
||||
d.rounded_rectangle(box(p, 0, 0, 456, 8), radius=p(4), fill=DIVIDER)
|
||||
frac = step / 5
|
||||
if frac > 0:
|
||||
# 用整体圆角矩形做蒙版,再按进度裁掉右半边,这样左端圆角自然、右端是直边
|
||||
mask = Image.new("L", (p(456), p(8)), 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle(box(p, 0, 0, 456, 8), radius=p(4), fill=255)
|
||||
fill_im = Image.new("RGB", (p(456), p(8)), PRIMARY)
|
||||
cut = max(1, int(round(p(456) * frac)))
|
||||
region = (0, 0, cut, p(8))
|
||||
im.paste(fill_im.crop(region), (0, 0), mask.crop(region))
|
||||
return down(im)
|
||||
|
||||
|
||||
def draw_stage(k, text):
|
||||
"""阶段文案位图,贴到进度页 (32,116):进度条上方一行,文案换行=状态切换。"""
|
||||
p = U(k)
|
||||
im = Image.new("RGB", (p(456), p(18)), CONTENT_BG)
|
||||
d = ImageDraw.Draw(im)
|
||||
f = font("Regular", 12.5, k)
|
||||
b = d.textbbox((0, 0), text, font=f, anchor="la")
|
||||
d.text((0, (p(18) - (b[3] - b[1])) / 2 - b[1]), text, font=f, fill=TXT_DIM, anchor="la")
|
||||
return down(im)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 汇总
|
||||
|
||||
def all_art(s):
|
||||
"""逐张产出某个倍率的全部素材。
|
||||
|
||||
为什么用生成器:k=8 时单张底图就有 70MB,一次性把几十张都留在内存里没必要。
|
||||
"""
|
||||
k = s * SS
|
||||
yield "base.bmp", draw_base(k)
|
||||
yield "brand.bmp", draw_brand(k)
|
||||
yield "titlebar.bmp", draw_titlebar(k)
|
||||
|
||||
yield "page-welcome.bmp", draw_page_welcome(k)
|
||||
yield "page-license.bmp", draw_page_license(k)
|
||||
yield "page-dir.bmp", draw_page_dir(k)
|
||||
yield "page-progress.bmp", draw_page_progress(k)
|
||||
yield "page-finish.bmp", draw_page_finish(k)
|
||||
|
||||
for label, name in (("下一步", "next"), ("安装", "install"), ("完成", "finish")):
|
||||
for st in ("normal", "hover", "pressed"):
|
||||
yield "btn-%s-%s.bmp" % (name, st), draw_button(k, label, BTN_W, BTN_H, "primary", st)
|
||||
for label, name in (("取消", "cancel"), ("上一步", "back")):
|
||||
for st in ("normal", "hover", "pressed"):
|
||||
yield "btn-%s-%s.bmp" % (name, st), draw_button(k, label, BTN_W, BTN_H, "secondary", st)
|
||||
for st in ("normal", "hover", "pressed"):
|
||||
yield "btn-browse-%s.bmp" % st, draw_button(k, "浏览…", 96, 34, "secondary", st)
|
||||
|
||||
for name in ("min", "close"):
|
||||
for st in ("normal", "hover", "pressed"):
|
||||
yield "tb-%s-%s.bmp" % (name, st), draw_tb_button(k, name, st)
|
||||
|
||||
for st in ("off", "on"):
|
||||
yield "check-%s.bmp" % st, draw_check(k, st == "on")
|
||||
yield "radio-%s.bmp" % st, draw_radio(k, st == "on")
|
||||
|
||||
for i in range(6):
|
||||
yield "progress-%d.bmp" % i, draw_progress(k, i)
|
||||
|
||||
for i, t in enumerate(["正在解压程序文件…", "正在写入注册表…", "正在创建快捷方式…",
|
||||
"正在放行防火墙…", "正在收尾…"], start=1):
|
||||
yield "stage-%d.bmp" % i, draw_stage(k, t)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 联络图
|
||||
|
||||
def compose(art, page, overlays):
|
||||
"""按真实层级拼出 760×480 的成品观感图:底图 → 品牌栏 → 页面 → 标题栏 → 按钮/控件。"""
|
||||
im = Image.new("RGB", (WIN_W, WIN_H), CONTENT_BG)
|
||||
im.paste(art["base.bmp"], (0, 0))
|
||||
im.paste(art["brand.bmp"], (0, TITLE_H))
|
||||
im.paste(art["page-%s.bmp" % page], (PAGE_X, PAGE_Y))
|
||||
im.paste(art["titlebar.bmp"], (0, 0))
|
||||
for name, (x, y) in overlays:
|
||||
im.paste(art[name], (x, y))
|
||||
return im
|
||||
|
||||
|
||||
def build_contact_sheet(art):
|
||||
"""五页各拼一张成品图,纵向排成长图给人眼过一遍。
|
||||
|
||||
单看素材文件看不出层级和对位问题(例如标题栏图形与按钮位图错位、按钮压住正文),
|
||||
所以验收必须看合成结果,而不是逐个位图看。
|
||||
"""
|
||||
# 标题栏按钮和底部按钮条按窗口坐标贴,页面内的控件按「页内坐标 + 页面原点」贴,
|
||||
# 两类坐标混用很容易少加一次偏移,所以这里用 loc() 明确区分
|
||||
def loc(x, y):
|
||||
return x + PAGE_X, y + PAGE_Y
|
||||
|
||||
tb = [("tb-min-normal.bmp", (TB_MIN_BOX[0], TB_MIN_BOX[1])),
|
||||
("tb-close-normal.bmp", (TB_CLOSE_BOX[0], TB_CLOSE_BOX[1]))]
|
||||
cancel = ("btn-cancel-normal.bmp", (BTN_SECONDARY_X, BTN_Y))
|
||||
back = ("btn-back-normal.bmp", (BTN_SECONDARY_X, BTN_Y))
|
||||
next_ = ("btn-next-normal.bmp", (BTN_PRIMARY_X, BTN_Y))
|
||||
|
||||
pages = [
|
||||
compose(art, "welcome", tb + [cancel, next_]),
|
||||
compose(art, "license", tb + [cancel, next_]),
|
||||
compose(art, "dir", tb + [
|
||||
("btn-browse-normal.bmp", loc(392, 110)),
|
||||
("radio-off.bmp", loc(32, 220)), ("radio-off.bmp", loc(32, 260)),
|
||||
back, next_,
|
||||
]),
|
||||
# 进度页取中间一档(第 3 段 / 60%),静态图里最能说明「安装中」的样子
|
||||
compose(art, "progress", tb + [
|
||||
("stage-3.bmp", loc(PAD_L, 116)),
|
||||
("progress-3.bmp", loc(PAD_L, 140)),
|
||||
cancel,
|
||||
]),
|
||||
compose(art, "finish", tb + [
|
||||
("check-on.bmp", loc(32, 298)),
|
||||
("btn-finish-normal.bmp", (BTN_PRIMARY_X, BTN_Y)),
|
||||
]),
|
||||
]
|
||||
|
||||
gap = 16
|
||||
sheet = Image.new("RGB", (WIN_W + gap * 2, gap + len(pages) * (WIN_H + gap)), "#202a38")
|
||||
for i, page in enumerate(pages):
|
||||
sheet.paste(page, (gap, gap + i * (WIN_H + gap)))
|
||||
os.makedirs(os.path.dirname(CONTACT_SHEET), exist_ok=True)
|
||||
sheet.save(CONTACT_SHEET)
|
||||
print("写出 %s %s" % (CONTACT_SHEET, sheet.size))
|
||||
|
||||
|
||||
def write_version_marker():
|
||||
"""把烘焙进位图的版本号另写一份纯文本旁证。
|
||||
|
||||
为什么需要这个文件:版本号是画在 brand.bmp 像素里的,构建脚本没法从位图反读出
|
||||
版本,因此「素材是旧版本生成的、但打包用的是新版本号」这种错配完全看不出来,
|
||||
会悄悄发出一个界面上写着错版本号的安装包。这里把 repo_version() 的结果落到
|
||||
art/version.txt,构建脚本拿它和仓库根 VERSION 一比,对不上就让构建失败。
|
||||
"""
|
||||
path = os.path.join(ART_DIR, "version.txt")
|
||||
with open(path, "w", encoding="utf-8", newline="\n") as fh:
|
||||
fh.write(repo_version() + "\n")
|
||||
print("写出 %s" % path)
|
||||
|
||||
|
||||
def main():
|
||||
art1x = {}
|
||||
for s in (1, 2):
|
||||
out = os.path.join(ART_DIR, "%dx" % s)
|
||||
os.makedirs(out, exist_ok=True)
|
||||
n = 0
|
||||
for name, im in all_art(s):
|
||||
im.save(os.path.join(out, name))
|
||||
n += 1
|
||||
if s == 1:
|
||||
art1x[name] = im # 联络图直接用 1x 成品,不要把 2x 缩一半再用(字会发虚)
|
||||
print("%s:%d 张" % (out, n))
|
||||
write_version_marker()
|
||||
build_contact_sheet(art1x)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 309 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 822 B |
|
After Width: | Height: | Size: 822 B |
|
After Width: | Height: | Size: 670 KiB |
|
After Width: | Height: | Size: 670 KiB |
|
After Width: | Height: | Size: 670 KiB |
|
After Width: | Height: | Size: 670 KiB |
|
After Width: | Height: | Size: 670 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 822 B |
|
After Width: | Height: | Size: 822 B |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |