feat(brand): add application logo for exe and window icon

新增品牌标识并接入运行时与打包流程。

- `scripts/gen_logo.py` 从单一几何定义同时产出 SVG 母版、多尺寸 ICO 和
  PNG 预览,避免母版与位图漂移;改样式改脚本后重新生成即可。
- 标记为暖橙圆角徽章 + 白色上升箭头(提升并发布)+ 四角星芒(AI 生成),
  刻意避开蝦皮官方购物袋标识,避免暗示官方关联。
- ICO 含 16/20/24/32/40/48/64/128/256 九档,每档从矢量几何独立渲染而非
  由大图缩放,保证任务栏与资源管理器小尺寸清晰。
- `app/assets` 作为可导入包提供无 Qt 依赖的路径解析,兼容源码树、
  PyInstaller onedir 的 _MEIPASS 与 exe 同级目录三种布局。
- `widgets.app_icon()` 在 Qt 缺失或资源未生成时返回 None,调用方容错;
  在 QApplication 上设置图标以便任务栏、对话框和消息框一并继承,
  MainWindow 另行设置以覆盖直接构造窗口的测试路径。
- 两个 spec 均加 `icon=`,主 spec 收集资源到 datas 并在资源缺失时直接
  报错提示先跑生成脚本。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
chengma
2026-07-20 16:39:18 +08:00
co-authored by Claude Fable 5
parent 6a0cd1c763
commit 049d4b6bbc
11 changed files with 291 additions and 1 deletions
+57
View File
@@ -0,0 +1,57 @@
"""Bundled static assets (application icon).
The icon ships inside the package so the same file backs the window icon at
runtime and the executable icon at build time. Keep this module free of Qt
imports: the packaging spec imports it while collecting data files.
Regenerate the files with ``py -3.10 scripts/gen_logo.py``.
"""
from __future__ import annotations
import os
import sys
ICON_NAME = "cmshopee.ico"
ICON_PNG_NAME = "cmshopee-256.png"
_PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
def _candidate_paths(name):
"""Locations to probe, covering source tree and PyInstaller onedir."""
yield os.path.join(_PACKAGE_DIR, name)
bundle_dir = getattr(sys, "_MEIPASS", None)
if bundle_dir:
yield os.path.join(bundle_dir, "app", "assets", name)
if getattr(sys, "frozen", False):
yield os.path.join(
os.path.dirname(os.path.abspath(sys.executable)),
"app",
"assets",
name,
)
def asset_path(name):
"""Return an existing asset path, or None when it is not bundled."""
for candidate in _candidate_paths(name):
if candidate and os.path.isfile(candidate):
return candidate
return None
def icon_path():
"""Path to the multi-size Windows icon, or None when unavailable."""
return asset_path(ICON_NAME)
def icon_png_path():
"""Path to the 256px PNG fallback, or None when unavailable."""
return asset_path(ICON_PNG_NAME)
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256" role="img" aria-label="蝦皮圈優化助手">
<title>蝦皮圈優化助手</title>
<defs>
<linearGradient id="badge" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#F5793F"/>
<stop offset="1" stop-color="#D6371C"/>
</linearGradient>
</defs>
<rect x="10" y="10" width="236" height="236" rx="54" fill="url(#badge)"/>
<path d="M 112 58 L 56 116 L 168 116 Z" fill="#FFFFFF"/>
<rect x="90" y="110" width="44" height="52" rx="10" fill="#FFFFFF"/>
<rect x="56" y="178" width="112" height="26" rx="13" fill="#FFFFFF"/>
<polygon points="194.0,40.0 201.8,66.2 228.0,74.0 201.8,81.8 194.0,108.0 186.2,81.8 160.0,74.0 186.2,66.2" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 777 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+5
View File
@@ -122,6 +122,11 @@ def main() -> int:
return 1
_ensure_offscreen_for_headless_tests()
app = QApplication.instance() or QApplication(sys.argv)
# Set on the application so the taskbar button, dialogs and message boxes
# all inherit it, not just the main window.
startup_icon = app_icon()
if startup_icon is not None:
app.setWindowIcon(startup_icon)
health_context = update_health.context_from_argv(
sys.argv,
appconfig.app_base_dir(),
+3
View File
@@ -98,6 +98,9 @@ class MainWindow(QMainWindow):
or appconfig.ai_models_config_path(self.config)
)
self.setWindowTitle(display_name())
window_icon = app_icon()
if window_icon is not None:
self.setWindowIcon(window_icon)
_fit_and_center_window(self)
self.setStyleSheet(BUTTON_BASE_STYLE)
self._settings_tab_index = TAB_TITLES.index("设置")
+18
View File
@@ -8,6 +8,8 @@ import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from .. import assets
try:
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, QTimer
from PySide6.QtGui import QColor, QIcon, QImage, QPainter, QPixmap
@@ -136,6 +138,22 @@ QPushButton:disabled {{
_RawQMessageBox = QMessageBox if QT_IMPORT_ERROR is None else None
def app_icon():
"""Application icon, or None when Qt is unavailable or the file is missing.
Callers must tolerate None: the icon is a packaged asset, and a source
checkout that has not run scripts/gen_logo.py still has to start.
"""
if QT_IMPORT_ERROR is not None:
return None
path = assets.icon_path() or assets.icon_png_path()
if not path:
return None
icon = QIcon(path)
return None if icon.isNull() else icon
class _GuiAttributeProxy:
def __init__(self, name, fallback):
self._name = name
+6
View File
@@ -1,5 +1,10 @@
# -*- mode: python ; coding: utf-8 -*-
import os
# Same mark as the main executable: the updater window belongs to this product.
APP_ICON = os.path.join(SPECPATH, "app", "assets", "cmshopee.ico")
a = Analysis(
["app/updater_entry.py"],
pathex=[],
@@ -25,4 +30,5 @@ exe = EXE(
strip=False,
upx=True,
console=False,
icon=APP_ICON,
)
+14 -1
View File
@@ -7,6 +7,8 @@ include config files, SQLite databases, Chrome profiles, generated images,
logs, prompts, or other operator data.
"""
import os
from PyInstaller.utils.hooks import collect_data_files
block_cipher = None
@@ -14,12 +16,22 @@ default_prompt_datas = collect_data_files(
"app.default_prompts",
includes=["**/*.txt"],
)
# Window icon at runtime; the same file is the executable icon below.
asset_datas = collect_data_files(
"app.assets",
includes=["*.ico", "*.png", "**/*.ico", "**/*.png"],
)
if not asset_datas:
raise SystemExit(
"app/assets 下没有找到图标资源,请先运行:py -3.10 scripts/gen_logo.py"
)
APP_ICON = os.path.join(SPECPATH, "app", "assets", "cmshopee.ico")
a = Analysis(
["main.py"],
pathex=[],
binaries=[],
datas=default_prompt_datas,
datas=default_prompt_datas + asset_datas,
hiddenimports=[
"PySide6.QtCore",
"PySide6.QtGui",
@@ -48,6 +60,7 @@ exe = EXE(
upx=True,
console=False,
disable_windowed_traceback=False,
icon=APP_ICON,
)
coll = COLLECT(
exe,
+2
View File
@@ -19,4 +19,6 @@
| [tab6-suite-prompt-dialog-T637.svg](tab6-suite-prompt-dialog-T637.svg) | 商品套图(T-637 提案):标题行「AI帮写/取消」紧随 label、最右新增「提示词设置」;提示词设置弹窗——左编辑基础模板(行序对齐调研笔记组装顺序,套图名称+补充描述、插入变量菜单、占位符校验、只读规则占位符内联)、右只读实时最终预览(预览分类下拉)、保存/恢复默认共用原子写盘;第一版不做同类多张序号/差异化 |
| [workbench-redesign-v1.html](workbench-redesign-v1.html) | 工作台视觉提案(HTML 可交互原型):以当前 6 Tab 信息架构为基础,统一「数据准备区→表格命令区→表格→状态反馈区→完成任务区」页面骨架;命令按作用域分区(导入/筛选/选择集/工作流完成),表格补齐当前行、选择集、三态全选与排序契约;范围确认、部分成功、取消中间态和破坏性确认按干扰层级分配到对话框、信息条与 Toast;覆盖浅色/深色/高对比度近似、键盘(Tab/方向键/F6/Ctrl+F/F5)与 Windows 断点。商品状态相关交互已对齐 T-662b/c/d、T-663~T-665、T-667 定稿(无状态列与状态下拉筛选,③无强制更新入口)。颜色 Token 取自 `app/gui/widgets.py`。使用模拟数据,不接生产接口;页脚「原型说明与验收边界」列出假设与不可仅靠 HTML 验收的项。 |
应用图标(非效果图,是随程序发布的资源):`app/assets/cmshopee-logo.svg` 是可编辑母版,`cmshopee.ico`(16–256 共 9 档)用于 exe 图标和 GUI 窗口左上角,`cmshopee-256.png` 为预览与非 Windows 回退。三者由 `py -3.10 scripts/gen_logo.py` 从同一份几何定义生成,改样式请改脚本后重新生成,不要单独手改某一个文件。
> 仅为效果图或交互原型,最终样式以实现为准。带 `-T584`/`-v2` 等后缀的为**改版提案**,未定稿。HTML 原型不能验证原生 Qt 标题栏、系统主题、DPI、无障碍树、线程和真实任务生命周期;界面职责与流程见 [../routes.md](../routes.md)。
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""Generate the cmshopee application logo.
Geometry lives here once and is emitted to both the editable SVG master and
the rasterised Windows icon, so the two can never drift apart.
Usage:
py -3.10 scripts/gen_logo.py
Outputs (all under app/assets/):
cmshopee-logo.svg editable master
cmshopee.ico multi-size Windows icon (exe + window icon)
cmshopee-256.png preview / non-Windows fallback
The mark is an upward arrow rising from a base bar: the product listing being
lifted and published. It deliberately avoids Shopee's own bag glyph so the
tool is not mistaken for an official Shopee application.
"""
from __future__ import annotations
import os
import sys
try:
from PIL import Image, ImageDraw
except ImportError: # pragma: no cover - developer tooling only
sys.exit("需要 Pillow 才能生成图标:py -3.10 -m pip install Pillow")
CANVAS = 256
SUPERSAMPLE = 8
ICO_SIZES = (16, 20, 24, 32, 40, 48, 64, 128, 256)
# Badge
BADGE = (10, 10, 246, 246) # x0, y0, x1, y1
BADGE_RADIUS = 54
GRADIENT_FROM = (245, 121, 63) # #F5793F
GRADIENT_TO = (214, 55, 28) # #D6371C
# Mark (white): an arrow lifting a listing off its base, plus an AI sparkle.
ARROW_HEAD = ((112, 58), (56, 116), (168, 116))
ARROW_STEM = (90, 110, 134, 162) # x0, y0, x1, y1
ARROW_STEM_RADIUS = 10
BASE_BAR = (56, 178, 168, 204)
BASE_BAR_RADIUS = 13
MARK_COLOR = (255, 255, 255)
# Four-point sparkle: the AI generation step. Degrades to a small accent dot
# at 16px rather than turning to mush.
SPARKLE_CENTER = (194, 74)
SPARKLE_OUTER = 34
SPARKLE_INNER = 11
ASSETS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "app", "assets")
def _hex(rgb):
return "#%02X%02X%02X" % rgb
def _gradient(size):
"""Diagonal linear gradient, built small and scaled up (it is smooth)."""
base = Image.new("RGB", (CANVAS, CANVAS))
pixels = base.load()
span = 2 * (CANVAS - 1)
for y in range(CANVAS):
for x in range(CANVAS):
t = (x + y) / span
pixels[x, y] = tuple(
round(GRADIENT_FROM[i] + (GRADIENT_TO[i] - GRADIENT_FROM[i]) * t)
for i in range(3)
)
return base.resize((size, size), Image.LANCZOS)
def _scaled(values, factor):
return [value * factor for value in values]
def _sparkle_points():
"""Eight alternating outer/inner points forming a four-point star."""
cx, cy = SPARKLE_CENTER
o, i = SPARKLE_OUTER, SPARKLE_INNER
diagonal = i * (2 ** 0.5) / 2
return [
(cx, cy - o),
(cx + diagonal, cy - diagonal),
(cx + o, cy),
(cx + diagonal, cy + diagonal),
(cx, cy + o),
(cx - diagonal, cy + diagonal),
(cx - o, cy),
(cx - diagonal, cy - diagonal),
]
def render(size):
"""Render one square icon at the given pixel size."""
work = size * SUPERSAMPLE
factor = work / CANVAS
badge_mask = Image.new("L", (work, work), 0)
draw = ImageDraw.Draw(badge_mask)
draw.rounded_rectangle(_scaled(BADGE, factor), radius=BADGE_RADIUS * factor, fill=255)
mark_mask = Image.new("L", (work, work), 0)
draw = ImageDraw.Draw(mark_mask)
draw.polygon([(x * factor, y * factor) for x, y in ARROW_HEAD], fill=255)
draw.rounded_rectangle(_scaled(ARROW_STEM, factor), radius=ARROW_STEM_RADIUS * factor, fill=255)
draw.rounded_rectangle(_scaled(BASE_BAR, factor), radius=BASE_BAR_RADIUS * factor, fill=255)
draw.polygon([(x * factor, y * factor) for x, y in _sparkle_points()], fill=255)
icon = Image.new("RGBA", (work, work), (0, 0, 0, 0))
icon.paste(_gradient(work), (0, 0), badge_mask)
icon.paste(Image.new("RGBA", (work, work), MARK_COLOR + (255,)), (0, 0), mark_mask)
return icon.resize((size, size), Image.LANCZOS)
def write_svg(path):
head = "M {} {} L {} {} L {} {} Z".format(*[c for point in ARROW_HEAD for c in point])
sparkle = " ".join(f"{x:.1f},{y:.1f}" for x, y in _sparkle_points())
svg = f"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {CANVAS} {CANVAS}" width="{CANVAS}" height="{CANVAS}" role="img" aria-label="蝦皮圈優化助手">
<title>蝦皮圈優化助手</title>
<defs>
<linearGradient id="badge" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="{_hex(GRADIENT_FROM)}"/>
<stop offset="1" stop-color="{_hex(GRADIENT_TO)}"/>
</linearGradient>
</defs>
<rect x="{BADGE[0]}" y="{BADGE[1]}" width="{BADGE[2] - BADGE[0]}" height="{BADGE[3] - BADGE[1]}" rx="{BADGE_RADIUS}" fill="url(#badge)"/>
<path d="{head}" fill="{_hex(MARK_COLOR)}"/>
<rect x="{ARROW_STEM[0]}" y="{ARROW_STEM[1]}" width="{ARROW_STEM[2] - ARROW_STEM[0]}" height="{ARROW_STEM[3] - ARROW_STEM[1]}" rx="{ARROW_STEM_RADIUS}" fill="{_hex(MARK_COLOR)}"/>
<rect x="{BASE_BAR[0]}" y="{BASE_BAR[1]}" width="{BASE_BAR[2] - BASE_BAR[0]}" height="{BASE_BAR[3] - BASE_BAR[1]}" rx="{BASE_BAR_RADIUS}" fill="{_hex(MARK_COLOR)}"/>
<polygon points="{sparkle}" fill="{_hex(MARK_COLOR)}"/>
</svg>
"""
with open(path, "w", encoding="utf-8") as fh:
fh.write(svg)
def main():
os.makedirs(ASSETS_DIR, exist_ok=True)
# Each size is rendered from the vector geometry rather than downscaled from
# one bitmap, so 16px and 20px stay crisp in Explorer and the taskbar.
images = [render(size) for size in ICO_SIZES]
largest = images[-1]
ico_path = os.path.join(ASSETS_DIR, "cmshopee.ico")
largest.save(
ico_path,
format="ICO",
sizes=[(size, size) for size in ICO_SIZES],
append_images=images[:-1],
)
png_path = os.path.join(ASSETS_DIR, "cmshopee-256.png")
largest.save(png_path, format="PNG")
svg_path = os.path.join(ASSETS_DIR, "cmshopee-logo.svg")
write_svg(svg_path)
for path in (svg_path, ico_path, png_path):
print(f"已生成 {os.path.relpath(path, os.path.dirname(ASSETS_DIR))} {os.path.getsize(path)} bytes")
if __name__ == "__main__":
main()