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 的双通道形态按要求改变。
No files matched your search
@@ -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 |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 356 KiB |
@@ -0,0 +1 @@
|
||||
0.5.1
|
||||
@@ -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 的安装段末尾)。它不会留在用户机器上。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||