feat(0.5.1): 业务逻辑审查 9 条 P0 全修 + 自绘安装器 + 现场反馈四项

## 算分正确性(6 份只读审查 → 收敛成 5 个病根,均先复现失败再修)
- 「人工名次」与「自动名次回写」互斥:原来同一份成绩**发布两次分数不同**
  (团体报名项 dual 通道 10/8/6 → 25/18/14),现在连发多次逐字节一致
- 删小项时在同一事务作废旧流水并推榜:原来分数**永久残留**在总榜、任何重算都打不到
- 规则包导入整体事务化:原来中途失败会清空名次表且补不回(奖牌全丢);
  另加守卫「compare=none 的小项不得配 basis=result/aggregate」
- 末位归零只抹真正落在末位的那一档:原来跨末位的并列组会被整组抹 0
  (7 人三人并列第 5 时第 5、6 名的分也一起没了)
- 模板「分值表档位超出录取名次」现在拒绝保存:原来静默截断成 0 分

## 现场反馈(主人实测报的)
- 已发布成绩可改:行内「修改本条」+ 成绩格/状态格可点(退回草稿→改→重新发布本组),
  与「作废」语义分开;取消路径不产生任何副作用
- 单位积分明细补「运动员 / 来源」列,并显示「已修订 ×N(时间·原因)」:
  管理端、大屏覆盖层、xlsx 备注列三处同源
- 单位奖牌榜改为按奖牌独立排名(金→银→铜→单位编码),与积分榜解耦
- 历史孤儿积分流水清理(迁移 0022,只动 result/record_bonus,人工调整分不受影响)

## 新功能
- 团体项目聚合口径二选一:平均(默认)/ 总和(迁移 0023,通道编辑器可切)

## 安装器
- 自绘五页安装界面:无边框、自绘标题栏、DPI 自适应(4K@200% 实测)、
  安装/卸载逻辑复用 electron-builder 宏不变;默认「为所有用户安装」并自动放行防火墙 19540
- 构建期校验:安装器素材里的版本号必须与 VERSION 一致,否则构建失败

自检:服务端 14 包 ×2 / 前端 vue-tsc + build + 742 例 ×2 / Console 20 文件 + 68 例 ×2,全绿。
端到端对拍:修前 vs 修后,纯团体 / 双计 / 普通个人三形态**逐字节一致**,仅出 bug 的双通道形态按要求改变。
This commit is contained in:
AskaEth committed 2026-09-13 15:20:18 +08:00
1 parent 78b14efaa9
commit 877ff15823
181 files changed
+8747 -371

No files matched your search

+1 -1
View File
@@ -1 +1 @@
dev_0.5.0
dev_0.5.1
+597
View File
@@ -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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

+1
View File
@@ -0,0 +1 @@
0.5.1
File diff suppressed because it is too large. Load diff
+10
View File
@@ -0,0 +1,10 @@
placeholder file for custom-script builds
========================================
本文件只是 electron-builder 模板宏 installApplicationFiles 需要的一个「肯定存在」的
File 源文件。用了自定义 installer 脚本(build.nsis.script)之后 electron-builder 不再
预编卸载器,UNINSTALLER_OUT_FILE 被留成空值,而那条 File 命令是无条件的 ——
不顶一个真文件上去,makensis 会直接以「no files found」失败。
安装时这个文件会被解到 $INSTDIR 下、紧接着被删掉,再由 WriteUninstaller 写出真正的
卸载器(见 console/installer/installer.nsi 的安装段末尾)。它不会留在用户机器上。
+3 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scoreboard-console",
"productName": "Scoreboard Console",
"version": "0.5.0",
"version": "0.5.1",
"private": true,
"author": "AskaEth",
"description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",
@@ -73,12 +73,14 @@
"artifactName": "${productName}-${version}-${arch}.${ext}"
},
"nsis": {
"script": "installer/installer.nsi",
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"shortcutName": "赛事积分榜",
"license": "../LICENSE",
"include": "resources/installer-custom.nsh",
"selectPerMachineByDefault": true,
"language": "2052"
}
}
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff. Show more