diff --git a/app/assets/__init__.py b/app/assets/__init__.py new file mode 100644 index 0000000..a9a05b5 --- /dev/null +++ b/app/assets/__init__.py @@ -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) diff --git a/app/assets/cmshopee-256.png b/app/assets/cmshopee-256.png new file mode 100644 index 0000000..c146485 Binary files /dev/null and b/app/assets/cmshopee-256.png differ diff --git a/app/assets/cmshopee-logo.svg b/app/assets/cmshopee-logo.svg new file mode 100644 index 0000000..a92e58d --- /dev/null +++ b/app/assets/cmshopee-logo.svg @@ -0,0 +1,14 @@ + + 蝦皮圈優化助手 + + + + + + + + + + + + diff --git a/app/assets/cmshopee.ico b/app/assets/cmshopee.ico new file mode 100644 index 0000000..bbe1923 Binary files /dev/null and b/app/assets/cmshopee.ico differ diff --git a/app/gui/__init__.py b/app/gui/__init__.py index be3a67b..3ba387a 100644 --- a/app/gui/__init__.py +++ b/app/gui/__init__.py @@ -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(), diff --git a/app/gui/main_window.py b/app/gui/main_window.py index 6a37062..699d2c8 100644 --- a/app/gui/main_window.py +++ b/app/gui/main_window.py @@ -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("设置") diff --git a/app/gui/widgets.py b/app/gui/widgets.py index 4e3618d..2f7ee82 100644 --- a/app/gui/widgets.py +++ b/app/gui/widgets.py @@ -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 diff --git a/cmshopee-updater.spec b/cmshopee-updater.spec index 454456b..c65234d 100644 --- a/cmshopee-updater.spec +++ b/cmshopee-updater.spec @@ -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, ) diff --git a/cmshopee.spec b/cmshopee.spec index b7b1e7a..4d7cc87 100644 --- a/cmshopee.spec +++ b/cmshopee.spec @@ -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, diff --git a/docs/ui/README.md b/docs/ui/README.md index 1210430..6067374 100644 --- a/docs/ui/README.md +++ b/docs/ui/README.md @@ -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)。 diff --git a/scripts/gen_logo.py b/scripts/gen_logo.py new file mode 100644 index 0000000..6542196 --- /dev/null +++ b/scripts/gen_logo.py @@ -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""" + 蝦皮圈優化助手 + + + + + + + + + + + + +""" + 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()