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