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()