diff --git a/app/gui/main_window.py b/app/gui/main_window.py index 2c831c1..f681f1b 100644 --- a/app/gui/main_window.py +++ b/app/gui/main_window.py @@ -8,11 +8,72 @@ from .tabs.collect import CollectTab from .tabs.generate import GenerateTab from .tabs.settings import SettingsTab from .widgets import * + +PREFERRED_WINDOW_SIZE = (1180, 760) +MIN_WINDOW_SIZE = (960, 640) +WINDOW_SCREEN_MARGIN = 40 + + +def _screen_available_geometry(): + app = QApplication.instance() + if app is None: + return None + screen = app.primaryScreen() + if screen is None: + return None + return screen.availableGeometry() + + +def _bounded_dimension(available_size, preferred_size, minimum_size, margin): + if available_size <= 0: + return preferred_size + if available_size > margin * 2: + maximum = available_size - margin * 2 + else: + maximum = available_size + if maximum >= minimum_size: + return max(minimum_size, min(preferred_size, maximum)) + return max(1, min(preferred_size, maximum)) + + +def _fit_and_center_window( + window, + available_geometry=None, + preferred_size=PREFERRED_WINDOW_SIZE, + minimum_size=MIN_WINDOW_SIZE, + margin=WINDOW_SCREEN_MARGIN, +): + available = available_geometry if available_geometry is not None else _screen_available_geometry() + if available is None or available.width() <= 0 or available.height() <= 0: + window.resize(*preferred_size) + return + + width = _bounded_dimension( + available.width(), + preferred_size[0], + minimum_size[0], + margin, + ) + height = _bounded_dimension( + available.height(), + preferred_size[1], + minimum_size[1], + margin, + ) + window.resize(width, height) + + x = available.x() + max(0, (available.width() - width) // 2) + y = available.y() + max(0, (available.height() - height) // 2) + max_x = available.x() + max(0, available.width() - width) + max_y = available.y() + max(0, available.height() - height) + window.move(min(max(x, available.x()), max_x), min(max(y, available.y()), max_y)) + class MainWindow(QMainWindow): """Main application window with the fixed five-tab workflow.""" def __init__(self, db_path=None, config=None, config_path=None, ai_models_path=None): super().__init__() + self._initial_window_fit_applied_after_show = False self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config self.config_path = ( config_path @@ -26,7 +87,7 @@ class MainWindow(QMainWindow): or appconfig.AI_MODELS_PATH ) self.setWindowTitle("蝦皮圈優化助手") - self.resize(1180, 760) + _fit_and_center_window(self) self.setStyleSheet(BUTTON_BASE_STYLE) self._settings_tab_index = TAB_TITLES.index("⑤ 设置") self._last_tab_index = 0 @@ -41,6 +102,12 @@ class MainWindow(QMainWindow): self.setCentralWidget(self.tabs) self.statusBar().showMessage("就绪") + def showEvent(self, event): + super().showEvent(event) + if not self._initial_window_fit_applied_after_show: + _fit_and_center_window(self) + self._initial_window_fit_applied_after_show = True + def _build_tab(self, title): if title == "① 导入采集": return CollectTab( diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 2ac61d0..468e006 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -141,6 +141,7 @@ | ID | 任务 | 依赖 | 验收要点 | 状态 | | --- | --- | --- | --- | --- | +| T-541 | 打包版主窗口初始位置与小屏适配 | T-523, T-537, T-540 | Bug:在 Windows 10 虚拟机/小分辨率环境运行打包后的 `dist/cmshopee/cmshopee.exe` 或 release 包时,主窗口可能贴到屏幕左上角且左边/标题栏显示不全,看不到「蝦皮圈優化助手 v」标题,用户无法拖动窗口到中间。根因:`MainWindow.__init__()` 只调用 `self.resize(1180, 760)`,没有根据 `QApplication.primaryScreen().availableGeometry()` 限制初始尺寸、没有居中、没有保证窗口 frame/title bar 在可用屏幕范围内;PyInstaller 本身不是根因,小屏、高 DPI、任务栏占用、多显示器或 VM 分辨率更容易触发。方案:① 抽一个主窗口初始布局 helper(如 `_fit_and_center_window(window, preferred_size=(1180,760), margin=40)`),读取当前屏幕 `availableGeometry()`;② 初始宽高不超过可用区域减安全边距,保留合理最小值(如 960x640,若屏幕更小则继续适配);③ `resize()` 后按可用区域中心 `move()`,确保窗口左上角不小于可用区域左上角,标题栏完整可见;④ offscreen/headless 测试环境不依赖真实屏幕时可跳过或用 mock geometry;⑤ 不保存/恢复历史窗口位置,避免把坏坐标持久化。同步 `docs/packaging.md` 打包验收:在目标 VM/小分辨率环境启动 release exe,窗口标题栏必须完整可见且可拖动。GUI 单测覆盖小屏 mock 下尺寸被限制、窗口被居中/夹进可用区域;不改业务流程、DB、AI、Excel、CDP/Shopee、打包产物结构 | DONE | | T-525 | 引入 ruff(lint + format)+ 可选 pre-commit | T-006 | 依据 `docs/engineering-review.md` P1。加 `ruff` 配置(lint + format),先以现状为基线不做大规模风格重排,只开启安全规则(未用 import/变量、明显错误);可选 `.pre-commit-config.yaml`;不改业务逻辑;CI(T-522)可串入 ruff 检查。数据模型渐进上 mypy 作为后续可选。当前因业务优先级调整顺延到 T-526~T-528 之后 | TODO | | T-537 | 品牌名「蝦皮圈優化助手」(显示层中文品牌,cmshopee 保留为代号/标识符) | T-523 | 需求:项目/软件正式定名「蝦皮圈優化助手」。**原则**:产品**显示/品牌名** = 「蝦皮圈優化助手」;`cmshopee` 保留为**代码库/仓库/技术代号**,一切**标识符不改**——遵项目「文档与 UI 文案用中文、标识符用英文」规则,且避免非 ASCII 文件名破坏现有用户数据。**改(显示/品牌层)**:① `app/gui/main_window.py` 窗口标题 `setWindowTitle("cmshopee")` → `"蝦皮圈優化助手"`(任务栏/标题栏显示中文品牌);② 文档产品名散文 `docs/00-ai-start-here.md`、`docs/README.md`、`AGENTS.md`(及 vision/brief 若有)把「cmshopee 是一个…」改为「蝦皮圈優化助手(代号 cmshopee)是一个…」,明确品牌名并保留代号;③ 空的 `README.md` 补品牌标题行;④ 可选:`app/gui/__init__.py` PySide6 缺失启动失败提示带上品牌名。**不改(技术标识符,保留 cmshopee)**:`app` 包名、`cmshopee.exe` 打包产物名、`cmshopee.db`(+`-wal/-shm`)/`db_path` 默认值、`cmshopee.spec`、`cmshopee.log`、`config.json`/`config/*.json` 文件名、slug/`chrome_user_data_dir`、仓库目录;`app/editor.py` 注入页面的 JS 全局 `window.__cmshopee_*`(JS 标识符须 ASCII)、`app/ai.py` multipart boundary `----cmshopee-*`、`app/workers.py` `thread._cmshopee_worker`、`app/chrome.py` 快捷方式内部前缀 `cmshopee-`。**决策点**:`app/excel.py` 回写文件名后缀 `_cmshopee回写` 为用户可见产物名——本任务**默认保留**(改动会影响已生成副本命名习惯),如需改「_優化助手回写」另行确认。**影响**:不改 db/exe/spec/包名 → 打包、更新流程、现有用户数据零影响。验收:窗口标题栏/任务栏/README/相关文档显示「蝦皮圈優化助手」;`cmshopee.db`/exe/包名不变;GUI 单测若断言窗口标题须同步更新;`python -m compileall app main.py` + `python -m unittest discover -s tests` 全绿。边界:纯显示/文档层,不改任何文件名、schema、流程、CDP/Shopee 逻辑 | DONE | | T-538 | 打包产物用户数据收进 `data/` 子文件夹(便携方案 1) | T-524 | 问题:当前 onedir 产物把用户数据(`config.json`、`config/ai_models.json`、`config/cmhub.json`、`cmshopee.db(+-wal/-shm)`、`chrome_user_data_dir/`、`images/`、`logs/`、`prompts/`、`title_prompt.txt`)生成在 exe 顶层,和程序文件(`cmshopee.exe`+`_internal/`)混放:更新易误删数据、放进 Program Files 只读目录会写失败/被 VirtualStore 重定向、程序与数据关注点混杂。方案(保持便携):① **统一数据根**——所有用户数据落到 exe 同级的单个 `data/` 子文件夹;`app/appconfig.py` 用显式 `data_dir`(默认 `/data`,源码运行时可为项目根/`data`)解析并返回绝对路径,**替代现在 `main.py` 的 chdir**(chdir 影响全进程相对路径/文件对话框默认目录,脆弱);文件名保持不变(仍是 `cmshopee.db` 等),只是移入 `data/`。② **旧布局迁移**——新版首次运行若在 exe 顶层发现旧的 `config.json`/`cmshopee.db` 等,自动迁移进 `data/`(或明确提示手动迁移),不得对老用户生成空 `data/` 丢失原数据。③ **可写性检测**——启动时若 `data_dir` 不可写(如放在 Program Files),弹清晰错误「请把程序放到可写目录,勿放 Program Files」并优雅退出,不静默失败。④ **发布只含程序**——`cmshopee.spec`/`scripts/build_exe.ps1` 产物仅 `cmshopee.exe`+`_internal/`,构建检查确保不打包 `data/` 或任何用户数据;发布 zip 不含 `data/`,从而「整包解压覆盖」也安全(只覆盖程序、不碰 `data/`)。⑤ **更新流程前提**——原地更新=换程序留 `data/`,依赖:DB 迁移幂等且只向前(现 `init_db`+`ALTER TABLE ADD COLUMN`)、`config` 向前兼容(`load_config` 补默认值)、`_internal/` 整体替换不合并。同步更新 `docs/packaging.md`(目录结构、首次运行数据位置、§5 更新步骤简化为「覆盖 exe+_internal,`data/` 不动」)。测试:`test_appconfig` 断言各路径解析进 `data_dir`、可写性检测行为、旧布局迁移;`test_packaging` 断言产物不含 `data/`/用户数据。边界:只改路径解析层(`appconfig` 路径 + `main.py` 数据根 + 迁移/可写检测)、spec/构建脚本、打包文档;不改文件名、DB schema、业务逻辑、AI/Excel、CDP/Shopee 流程 | DOING | diff --git a/docs/current-state.md b/docs/current-state.md index c008929..1714d20 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -110,6 +110,7 @@ - T-523a 已完成:②点击「开始生成」、③点击「检查本轮更新」或「开始更新」时,先清空对应界面的旧日志显示并写入本轮开始摘要;运行中只追加本轮日志。历史 `run_logs/run_log_events` 和本地 `logs/` 不删除、不自动混入当前运行界面。 - T-404b 已完成:`open_product()` 在导航商品详情页前后安装 toast 捕获,等待详情页关键元素超时时会读取最近 `.eds-toasts`/toast/message 节点及页面缓存,把明确商品失效/商品不存在/无权限类 toast 上浮为 `商品失效:<原始toast>`;如果失败发生在 `open_product()` 内部,本轮自动新建的失败 tab 会关闭,复用用户已有 tab 不关闭;①列表“阶段”列只在这类明确失效错误时显示“商品失效”,底层仍保持 `stage=imported/status=failed`,其他打开失败仍显示“失败”。 - T-524 已完成:新增 PyInstaller onedir 打包配置、构建脚本和发布目录校验;打包版启动时工作目录切到 `cmshopee.exe` 所在目录,首次运行在 exe 同级生成/使用本地配置、DB、图片、日志、登录态和提示词;发布包不内置这些本地数据,用户后续更新采用关闭程序后覆盖程序文件、保留本地数据的方式。 +- T-541 已完成:`MainWindow` 启动时通过 `_fit_and_center_window()` 读取 `QApplication.primaryScreen().availableGeometry()`,按可用屏幕限制初始尺寸、居中并夹进屏幕;首次 `showEvent` 后再应用一次,保证打包版在 Windows 10 VM/小分辨率环境标题栏完整可见、可拖动,不保存历史坏坐标。 - Phase 7 cmhub 网关对接已完成 T-526~T-535;②生成阶段已支持按标题/封面缺失组件增量生成,②重置生成结果已支持多选/筛选范围和按标题/封面组件重置,cmhub 标题请求读取等待固定 600 秒。下一步按看板回到 T-525 ruff。 - T-301/T-303 已完成通用 HTTP AI 接口、批量生成编排和 GUI 接入 mock 单测;真实 AI 生成还需要在 `config/ai_models.json` 填入可用 url/model/api_key 后做一次成本可控的小样本实测;URL 可填完整 endpoint 或 OpenAI-compatible base URL。 diff --git a/docs/packaging.md b/docs/packaging.md index 8088cda..cc79582 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -5,7 +5,7 @@ ## 一、打包前提 - 在 Windows 环境执行。 -- Python 环境已能运行源码版 `python main.py`。 +- Python 环境已能运行源码版 `python main.py`。 - 打包脚本使用当前 PATH 中的 `python`;正式出包前先确认 `python --version` 符合项目目标 Python 3.10+。 - 安装运行依赖和打包依赖: @@ -95,6 +95,7 @@ powershell -ExecutionPolicy Bypass -File scripts\build_exe.ps1 - `dist\cmshopee\cmshopee.exe` 存在。 - `dist\cmshopee\` 中没有第三节列出的本地数据。 - 在干净目录首次启动时能生成默认配置并进入 GUI。 +- 在目标 Windows 10/11 机器或虚拟机上启动 release exe 后,主窗口标题栏完整可见,左边缘不出屏,用户能用标题栏拖动窗口;小分辨率环境不得出现窗口卡在左上角且标题栏不可拖动的问题(见 T-541)。 + +涉及 Shopee/CDP 的真实更新能力,仍按任务文档要求用测试商品做人工回归;打包任务本身不新增自动绕过登录、验证码或风控的能力。 -涉及 Shopee/CDP 的真实更新能力,仍按任务文档要求用测试商品做人工回归;打包任务本身不新增自动绕过登录、验证码或风控的能力。 - diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 7ceff10..9a00a16 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -227,3 +227,41 @@ T-530 已实现:保存和请求前都会把 Base URL 规整为网关根,去 已修复:T-105b 已把④「启动登录」改成幂等流程。先检查该账号 `debug_port` 是否已有 CDP 响应;已响应时不再 `Popen`,而是复用现有 Chrome/CDP 并打开或激活 `https:///portal/` 登录/卖家中心 tab;未响应时才新启动 Chrome。该修复不自动登录、不填密码、不绕过验证码,也不改变①/③预检“不自动启动 Chrome”的规则。 验证方式:同一账号第一次点击「启动登录」应显示“Chrome 已启动,请人工登录”;保持该账号 Chrome 不关闭,再点一次应显示“该账号 Chrome 已打开,已复用现有窗口”,且不会新增 Chrome 进程。 + +## 打包版窗口左上角显示不全,标题栏不可拖动 + +### 现象 + +在 Windows 10 虚拟机或小分辨率环境运行打包后的 `dist/cmshopee/cmshopee.exe` / release 包后,主窗口出现在屏幕左上角,左边和标题栏显示不全,看不到「蝦皮圈優化助手」标题,导致无法用鼠标拖动窗口到中间。 + +### 原因 + +旧逻辑只设置固定窗口大小: + +```python +self.resize(1180, 760) +``` + +没有按当前屏幕可用区域限制初始尺寸、居中显示,也没有保证窗口标题栏完整落在屏幕内。虚拟机、小分辨率、高 DPI、任务栏占用较多或多显示器切换环境下更容易暴露该问题;PyInstaller 打包不是根因。 + +### 临时处理 + +如果已经遇到窗口无法拖动: + +1. 按 `Alt + Space`。 +2. 按 `M` 选择移动。 +3. 用方向键把窗口移回屏幕中间,再按回车。 + +也可以尝试 `Win + 方向键` 贴靠/恢复窗口,或先调高虚拟机分辨率。 + +### 当前修复(T-541 后) + +T-541 已在主窗口启动时读取 `QApplication.primaryScreen().availableGeometry()`: + +- 大屏保持接近 `1180x760`。 +- 小屏自动缩小到可用区域内。 +- `resize()` 后按可用区域居中 `move()`。 +- 确保窗口左上角不小于可用区域左上角,标题栏完整可见、可拖动。 +- 不保存坏的历史窗口坐标,避免下次继续打开到屏幕外。 + +修复后需要在普通桌面和 Windows 10 虚拟机/小分辨率环境各启动一次打包版,确认标题栏完整可见。 diff --git a/progress.md b/progress.md index e03e311..39afe04 100644 --- a/progress.md +++ b/progress.md @@ -1271,3 +1271,19 @@ - 代码:`app/gui/main_window.py` 主窗口标题改为「蝦皮圈優化助手」;`app/gui/__init__.py` 的 PySide6 缺失启动提示带上中文品牌名;`tests/test_gui.py` 补充窗口标题断言。 - 边界:只改显示层和文档层;`cmshopee` 继续作为仓库、包名、exe、数据库、日志、配置、回写后缀和兼容标识符保留;未改任何文件名、schema、AI、Excel、DB、CDP 或 Shopee 更新流程。 - 验证:`python -m py_compile app\gui\main_window.py app\gui\__init__.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p test_gui.py` 通过(95 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过;`git diff --check` 通过。 + +## 【2026-07-07】T-541 文档 · 打包版窗口左上角显示不全 + +- 现象:运行打包后的 `dist/cmshopee/cmshopee.exe` / release 包时,Windows 10 虚拟机或小分辨率环境可能出现主窗口贴到屏幕左上角、左边和标题栏显示不全,用户看不到品牌标题且无法拖动窗口到中间。 +- 原因:`MainWindow.__init__()` 当前只 `resize(1180, 760)`,没有读取 `QApplication.primaryScreen().availableGeometry()` 限制初始尺寸、居中显示,也没有保证窗口标题栏完整落在可用屏幕内。 +- 文档:`docs/06-tasks.md` 新增 T-541;`docs/troubleshooting.md` 增加临时处理(`Alt+Space` → `M` → 方向键移动)和正式修复方案;`docs/packaging.md` 打包验收清单新增标题栏完整可见/可拖动检查;`docs/current-state.md` 记录 T-541 修复口径。 +- 方案:后续代码在主窗口启动时根据屏幕可用区域限制尺寸并居中,保证标题栏可见;小屏继续适配,不保存坏的历史窗口坐标。 +- 边界:本轮只更新文档,未改 GUI 代码、打包脚本、业务逻辑、DB、AI、Excel、CDP 或 Shopee 流程。 + +## 【2026-07-07】T-541 完成 · 打包版主窗口初始位置与小屏适配 + +- 状态:DONE +- 代码:`app/gui/main_window.py` 新增 `_fit_and_center_window()`,启动时读取 `QApplication.primaryScreen().availableGeometry()`,将主窗口尺寸限制在可用屏幕范围内并居中;首次 `showEvent` 后再应用一次,避免打包版在 Windows 10 VM/小分辨率环境标题栏出屏。 +- 文档:`docs/06-tasks.md` 将 T-541 标记为 DONE;`docs/troubleshooting.md`、`docs/packaging.md`、`docs/current-state.md` 更新为当前修复与打包验收口径。 +- 测试:`tests/test_gui.py` 新增小屏和大屏 mock geometry 单测,覆盖尺寸限制与居中位置;`scripts/build_exe.ps1` 已重新构建 release,确认 T-541 合入后仍可打包。 +- 边界:不保存/恢复窗口历史坐标;未改业务流程、DB、AI、Excel、CDP/Shopee、打包产物结构或用户数据路径。 diff --git a/tests/test_gui.py b/tests/test_gui.py index d0a5ea8..3cfba36 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -17,7 +17,7 @@ from app import accounts, ai, appconfig, db, prompts if gui.QT_IMPORT_ERROR is not None: raise unittest.SkipTest("PySide6 未安装") -from PySide6.QtCore import QItemSelectionModel +from PySide6.QtCore import QItemSelectionModel, QRect from PySide6.QtGui import QTextCursor from PySide6.QtWidgets import QApplication, QCheckBox, QLineEdit, QPlainTextEdit, QProgressBar, QTableView @@ -39,6 +39,7 @@ from app.gui import ( TAB_TITLES, WriteBackWorker, ) +from app.gui.main_window import _fit_and_center_window class GuiTests(TempDirMixin, unittest.TestCase): @@ -182,6 +183,42 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_main_window_initial_fit_limits_size_on_small_screen(self): + class FakeWindow: + def __init__(self): + self.size = None + self.position = None + + def resize(self, width, height): + self.size = (width, height) + + def move(self, x, y): + self.position = (x, y) + + window = FakeWindow() + _fit_and_center_window(window, available_geometry=QRect(0, 0, 900, 700)) + + self.assertEqual((820, 620), window.size) + self.assertEqual((40, 40), window.position) + + def test_main_window_initial_fit_centers_preferred_size_on_large_screen(self): + class FakeWindow: + def __init__(self): + self.size = None + self.position = None + + def resize(self, width, height): + self.size = (width, height) + + def move(self, x, y): + self.position = (x, y) + + window = FakeWindow() + _fit_and_center_window(window, available_geometry=QRect(100, 50, 1600, 900)) + + self.assertEqual((1180, 760), window.size) + self.assertEqual((310, 120), window.position) + def test_collect_tab_shows_empty_state_without_accounts(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir)