diff --git a/app/gui/tabs/image_studio.py b/app/gui/tabs/image_studio.py index cc256e4..1d7785a 100644 --- a/app/gui/tabs/image_studio.py +++ b/app/gui/tabs/image_studio.py @@ -20,6 +20,8 @@ from ..workers import ImageStudioResumeJobsWorker as _RealImageStudioResumeJobsW ASSET_MIME_TYPE = "application/x-cmshopee-image-studio-asset" +ORIGINAL_DOWNLOAD_CONCURRENCY = 2 +_ORIGINAL_DOWNLOAD_THREAD_REFS = {} def ImageStudioPullImagesWorker(*args, **kwargs): @@ -298,6 +300,10 @@ class ImageStudioTab(QWidget): self._thumbnail_pending = set() self._thumbnail_errors = set() self._thumbnail_project_id = None + self._original_downloads = {} + self._original_download_queue = [] + self._original_download_states = {} + self._original_downloads_closing = False self._thumbnail_signals = _ThumbnailSignals(self) self._thumbnail_signals.loaded.connect(self._on_thumbnail_loaded) self._thumbnail_signals.failed.connect(self._on_thumbnail_failed) @@ -316,15 +322,16 @@ class ImageStudioTab(QWidget): root.addWidget(self._build_top_bar(), 0) - splitter = QSplitter(Qt.Horizontal) - splitter.setObjectName("imageStudioMainSplitter") - splitter.addWidget(self._build_project_panel()) - splitter.addWidget(self._build_pool_panel()) - splitter.addWidget(self._build_generation_panel()) - splitter.setStretchFactor(0, 1) - splitter.setStretchFactor(1, 3) - splitter.setStretchFactor(2, 2) - root.addWidget(splitter, 1) + self.main_splitter = QSplitter(Qt.Horizontal) + self.main_splitter.setObjectName("imageStudioMainSplitter") + self.main_splitter.addWidget(self._build_project_panel()) + self.main_splitter.addWidget(self._build_pool_panel()) + self.main_splitter.addWidget(self._build_generation_panel()) + self.main_splitter.setStretchFactor(0, 0) + self.main_splitter.setStretchFactor(1, 4) + self.main_splitter.setStretchFactor(2, 2) + self.main_splitter.setSizes([150, 640, 360]) + root.addWidget(self.main_splitter, 1) root.addWidget(self._build_final_panel(), 0) self._apply_workspace_style() self._update_generation_action_text() @@ -392,7 +399,7 @@ class ImageStudioTab(QWidget): self.project_table.setEditTriggers(QAbstractItemView.NoEditTriggers) self.project_table.horizontalHeader().setStretchLastSection(True) self.project_table.verticalHeader().setVisible(False) - self.project_table.setMinimumWidth(220) + self.project_table.setMinimumWidth(150) layout.addWidget(self.project_table, 1) return panel @@ -412,6 +419,10 @@ class ImageStudioTab(QWidget): original_title = QLabel("蝦皮原主图") original_title.setObjectName("imageStudioSectionTitle") original_header.addWidget(original_title) + self.original_download_label = QLabel("") + self.original_download_label.setObjectName("imageStudioOriginalDownloadLabel") + self.original_download_label.setMinimumWidth(156) + original_header.addWidget(self.original_download_label) original_header.addStretch(1) self.original_hint_label = QLabel("单击下载并加入照片池,双击查看大图") self.original_hint_label.setObjectName("imageStudioOriginalHintLabel") @@ -420,10 +431,10 @@ class ImageStudioTab(QWidget): self.original_grid = ImageStudioThumbnailGrid(parent=self) self.original_grid.setObjectName("imageStudioOriginalGrid") - self.original_grid.setIconSize(QSize(62, 62)) - self.original_grid.setGridSize(QSize(78, 92)) - self.original_grid.setMinimumHeight(174) - self.original_grid.setMaximumHeight(202) + self.original_grid.setIconSize(QSize(48, 48)) + self.original_grid.setGridSize(QSize(58, 78)) + self.original_grid.setMinimumHeight(94) + self.original_grid.setMaximumHeight(110) self.original_grid.setContextMenuPolicy(Qt.CustomContextMenu) layout.addWidget(self.original_grid, 0) @@ -645,6 +656,9 @@ class ImageStudioTab(QWidget): color: #24292f; font-weight: 600; } + #imageStudioOriginalDownloadLabel { + color: #0969da; + } #imageStudioSourcePreview { border: 1px dashed #bfc8d3; border-radius: 6px; @@ -751,6 +765,26 @@ class ImageStudioTab(QWidget): if not alias or not item_id: self._message("信息未填写完整", "请先选择店铺并输入商品ID,再拉取蝦皮主图。") return + project = image_studio.get_project_by_account_item( + alias, + item_id, + path=self.db_path, + include_deleted=True, + ) + if project is not None and self._has_original_download_activity(project.id): + self._message( + "原图下载尚未完成", + "当前商品还有蝦皮原主图正在下载或等待下载,请等待完成后再重新拉取。", + ) + return + local_count = self._local_original_count(project) + if local_count and not self._confirm( + "重新拉取蝦皮主图", + f"本地已保存 {local_count} 张蝦皮原主图。是否重新拉取线上主图?\n" + "重新拉取会刷新当前线上主图列表;不会删除本地图片、生成记录或终选记录。", + ): + self._status("已取消重新拉取蝦皮主图", "muted") + return worker = ImageStudioPullImagesWorker( alias, item_id, @@ -763,6 +797,135 @@ class ImageStudioTab(QWidget): self._start_worker(worker, "AI工场拉取蝦皮主图") self._append_log("[AI工场] 拉取蝦皮主图开始") + def _local_original_count(self, project): + if project is None: + return 0 + try: + originals = image_studio.list_assets( + project.id, + kind=image_studio.ASSET_KIND_ORIGINAL, + path=self.db_path, + ) + except Exception: + return 0 + return sum( + 1 + for asset in originals + if str(asset.local_path or "").strip() and os.path.isfile(asset.local_path) + ) + + def _has_original_download_activity(self, project_id): + """Return whether a project still owns queued or live original downloads.""" + + if project_id is None: + return False + target_project_id = int(project_id) + if any( + int(request.get("project_id") or 0) == target_project_id + for request in self._original_download_queue + ): + return True + return any( + int(record.get("project_id") or 0) == target_project_id + for record in self._original_downloads.values() + ) + + def _original_download_count(self): + return len(self._original_downloads) + + def _original_asset_order(self, asset_id): + for index, asset in enumerate(self.assets, start=1): + if int(asset.id) == int(asset_id): + return int(asset.source_order or index) + asset = image_studio.get_asset(asset_id, path=self.db_path) + if asset is not None: + return int(asset.source_order or 1) + return 1 + + def _set_original_download_state(self, asset_id, project_id, state, **fields): + payload = { + "project_id": int(project_id), + "state": str(state), + } + payload.update(fields) + self._original_download_states[int(asset_id)] = payload + + def _clear_original_download_state(self, asset_id, *, keep_failed=False): + state = self._original_download_states.get(int(asset_id)) + if keep_failed and state and state.get("state") == "failed": + return + self._original_download_states.pop(int(asset_id), None) + + def _original_card_state(self, asset): + asset_id = int(asset.id) + state = self._original_download_states.get(asset_id, {}) + state_name = state.get("state") + if state_name == "queued": + return "等待下载" + if state_name == "downloading": + return "下载中" + if state_name == "retrying": + return f"重试 {state.get('retry', 1)}/{state.get('max_retries', 2)}" + if state_name == "failed": + return "下载失败" + return self._original_thumbnail_state(asset) + + def _refresh_original_download_label(self): + project = self.current_project + if project is None: + self.original_download_label.setText("") + return + project_id = int(project.id) + states = [ + (asset_id, state) + for asset_id, state in self._original_download_states.items() + if int(state.get("project_id") or 0) == project_id + ] + retrying = [item for item in states if item[1].get("state") == "retrying"] + active = [ + item + for item in states + if item[1].get("state") in {"downloading", "retrying"} + ] + queued = [item for item in states if item[1].get("state") == "queued"] + failed = [item for item in states if item[1].get("state") == "failed"] + if retrying: + asset_id, state = retrying[0] + self.original_download_label.setText( + "蝦皮原主图 #%s 下载失败,正在重试 %s/%s" + % ( + self._original_asset_order(asset_id), + state.get("retry", 1), + state.get("max_retries", 2), + ) + ) + return + if active: + orders = "、".join( + "#%s" % self._original_asset_order(asset_id) + for asset_id, _state in active + ) + text = ( + f"正在下载蝦皮原主图 {orders}({len(active)}/{ORIGINAL_DOWNLOAD_CONCURRENCY})" + ) + if failed: + failed_asset_id, _state = failed[0] + text += ( + f";主图 #{self._original_asset_order(failed_asset_id)} 下载失败,可再次点击图片重试" + ) + self.original_download_label.setText(text) + return + if queued: + asset_id, _state = queued[0] + self.original_download_label.setText( + f"蝦皮原主图 #{self._original_asset_order(asset_id)} 等待下载" + ) + return + if failed: + self.original_download_label.setText("蝦皮原主图下载失败,可再次点击图片重试") + return + self.original_download_label.setText("") + def _on_pull_finished(self, summary): if self._handle_finished_error(summary, "拉取蝦皮主图失败"): return @@ -796,6 +959,12 @@ class ImageStudioTab(QWidget): if project is None: self._message("未选择商品", "请先从商品列表选择要删除的项目。") return + if self._has_original_download_activity(project.id): + self._message( + "暂不能删除项目", + "该商品还有蝦皮原主图正在下载或等待下载,请等待完成后再删除。", + ) + return active_jobs = [ job for job in self._list_project_jobs(project.id) @@ -894,18 +1063,20 @@ class ImageStudioTab(QWidget): self.original_grid.clear() for row, asset in enumerate(originals): order = asset.source_order or row + 1 - item = QListWidgetItem(f"主图 {order}\n{self._original_thumbnail_state(asset)}") + item = QListWidgetItem(f"主图 {order}\n{self._original_card_state(asset)}") item.setData(Qt.UserRole, {"type": "asset", "asset_id": int(asset.id)}) item.setIcon( _asset_icon( asset, "原", - size=QSize(62, 62), + size=QSize(48, 48), cached_pixmap=self._thumbnail_pixmaps.get(int(asset.id)), ) ) - item.setSizeHint(QSize(78, 92)) + item.setSizeHint(QSize(58, 78)) tooltip = "单击下载并加入照片池,双击查看大图。缩略图仅用于预览,不保存原图。" + if asset.status == image_studio.ASSET_STATUS_MISSING and str(asset.local_path or "").strip(): + tooltip += "\n该图片不在当前蝦皮主图列表中,本地历史文件仍已保留。" if int(asset.id) in self._thumbnail_errors: tooltip += "\n缩略图加载失败,可右键重新加载,或直接单击下载原图。" item.setToolTip(tooltip) @@ -915,7 +1086,8 @@ class ImageStudioTab(QWidget): item = self.original_grid.item(row) if item is not None: order = asset.source_order or row + 1 - item.setText(f"主图 {order}\n{self._original_thumbnail_state(asset)}") + item.setText(f"主图 {order}\n{self._original_card_state(asset)}") + self._refresh_original_download_label() def _fill_pool_grid(self): rows = [] @@ -1067,23 +1239,142 @@ class ImageStudioTab(QWidget): if open_after: self._open_preview(asset) return - worker = ImageStudioDownloadOriginalWorker( - asset.id, - db_path=self.db_path, - config=self.config, - open_after=open_after, - ) - worker.log.connect(self._append_log) - worker.finished.connect(self._on_download_finished) - worker.failed.connect(self._on_worker_failed) - self._start_worker(worker, "AI工场下载原图") - def _on_download_finished(self, summary): - if self._handle_finished_error(summary, "下载原图失败"): + self._enqueue_original_download(asset, open_after=open_after) + + def _enqueue_original_download(self, asset, *, open_after=False): + """Queue one full original download without locking the workspace.""" + + asset_id = int(asset.id) + project_id = int(asset.project_id) + active = self._original_downloads.get(asset_id) + if active is not None: + if open_after: + active["open_after"] = True + worker = active.get("worker") + if worker is not None: + worker.open_after = True + self._status( + f"蝦皮原主图 #{self._original_asset_order(asset_id)} 正在下载,请稍候", + "info", + ) + return + for request in self._original_download_queue: + if int(request.get("asset_id") or 0) != asset_id: + continue + request["open_after"] = bool(request.get("open_after") or open_after) + self._status( + f"蝦皮原主图 #{self._original_asset_order(asset_id)} 已在下载队列中", + "info", + ) + return + + self._set_original_download_state(asset_id, project_id, "queued") + self._original_download_queue.append( + { + "asset_id": asset_id, + "project_id": project_id, + "open_after": bool(open_after), + } + ) + self._refresh_original_download_ui(project_id) + self._start_queued_original_downloads() + + def _start_queued_original_downloads(self): + while ( + self._original_download_queue + and self._original_download_count() < ORIGINAL_DOWNLOAD_CONCURRENCY + ): + request = self._original_download_queue.pop(0) + asset_id = int(request["asset_id"]) + project_id = int(request["project_id"]) + try: + asset = image_studio.get_asset(asset_id, path=self.db_path) + except Exception: + asset = None + if asset is None or int(asset.project_id) != project_id: + self._clear_original_download_state(asset_id) + continue + worker = ImageStudioDownloadOriginalWorker( + asset_id, + db_path=self.db_path, + config=self.config, + open_after=bool(request.get("open_after")), + max_retries=2, + ) + worker.log.connect(self._append_log) + worker.progress.connect( + lambda payload, aid=asset_id, pid=project_id: self._on_original_download_progress( + aid, pid, payload + ) + ) + worker.finished.connect( + lambda summary, aid=asset_id, pid=project_id: self._on_original_download_finished( + aid, pid, summary + ) + ) + worker.cancelled.connect( + lambda summary, aid=asset_id, pid=project_id: self._on_original_download_cancelled( + aid, pid, summary + ) + ) + thread = run_worker(worker, thread_name="AI工场下载蝦皮原主图", start=False) + thread.finished.connect( + lambda aid=asset_id, active_thread=thread: self._forget_original_download( + aid, active_thread + ) + ) + _ORIGINAL_DOWNLOAD_THREAD_REFS[id(thread)] = (thread, worker) + self._original_downloads[asset_id] = { + "worker": worker, + "thread": thread, + "project_id": project_id, + "open_after": bool(request.get("open_after")), + } + self._set_original_download_state(asset_id, project_id, "downloading") + self._refresh_original_download_ui(project_id) + thread.start() + + def _on_original_download_progress(self, asset_id, project_id, payload): + if self._original_downloads_closing: + return + if not isinstance(payload, dict): + return + state = str(payload.get("state") or "") + if state == "retry": + self._set_original_download_state( + asset_id, + project_id, + "retrying", + retry=int(payload.get("retry") or 1), + max_retries=int(payload.get("max_retries") or 2), + ) + elif state in {"start", "success"}: + self._set_original_download_state(asset_id, project_id, "downloading") + elif state == "failed": + self._set_original_download_state(asset_id, project_id, "failed") + self._refresh_original_download_ui(project_id) + + def _on_original_download_finished(self, asset_id, project_id, summary): + if self._original_downloads_closing: + self._clear_original_download_state(asset_id) + return + summary = dict(summary or {}) + if summary.get("ok") is False: + self._set_original_download_state(asset_id, project_id, "failed") + self._refresh_original_download_ui(project_id) + if self.current_project is not None and int(self.current_project.id) == project_id: + self._status( + f"蝦皮原主图 #{self._original_asset_order(asset_id)} 下载失败,可再次点击图片重试", + "warning", + ) + return + + self._clear_original_download_state(asset_id) + if self.current_project is None or int(self.current_project.id) != project_id: return - asset = summary.get("asset") - self._finish_worker() self.refresh_project_assets() + asset = summary.get("asset") if asset is not None: refreshed = self._asset_by_id(asset.id) or asset self._select_source_asset(refreshed) @@ -1091,6 +1382,39 @@ class ImageStudioTab(QWidget): self._open_preview(refreshed) self._status("原图已加入照片池", "success") + def _on_original_download_cancelled(self, asset_id, project_id, summary): + self._clear_original_download_state(asset_id) + if self._original_downloads_closing: + return + self._refresh_original_download_ui(project_id) + + def _forget_original_download(self, asset_id, thread): + _ORIGINAL_DOWNLOAD_THREAD_REFS.pop(id(thread), None) + record = self._original_downloads.get(int(asset_id)) + if record is None or record.get("thread") is not thread: + return + project_id = int(record.get("project_id") or 0) + self._original_downloads.pop(int(asset_id), None) + if self._original_downloads_closing: + return + self._refresh_original_download_ui(project_id) + self._start_queued_original_downloads() + + def _refresh_original_download_ui(self, project_id): + if self.current_project is None or int(self.current_project.id) != int(project_id): + return + self._fill_original_grid() + self._update_project_action_buttons() + + def _cancel_original_downloads(self): + for request in self._original_download_queue: + self._clear_original_download_state(request.get("asset_id")) + self._original_download_queue = [] + for record in list(self._original_downloads.values()): + worker = record.get("worker") + if worker is not None: + worker.cancel() + def _select_source_asset(self, asset): self.selected_source_asset_id = int(asset.id) self._refresh_source_label() @@ -1145,7 +1469,12 @@ class ImageStudioTab(QWidget): def _update_project_action_buttons(self): enabled = self.current_project is not None and not self._operation_running self.open_folder_button.setEnabled(enabled) - self.delete_project_button.setEnabled(enabled) + self.delete_project_button.setEnabled( + enabled + and not self._has_original_download_activity( + getattr(self.current_project, "id", None) + ) + ) def _thumbnail_key(self, project_id, asset_id): return f"{int(project_id)}:{int(asset_id)}" @@ -1746,6 +2075,8 @@ class ImageStudioTab(QWidget): _emit_status(self.status_callback, message, level=level) def closeEvent(self, event): + self._original_downloads_closing = True + self._cancel_original_downloads() try: self._thumbnail_loader.close() except Exception: diff --git a/app/gui/workers.py b/app/gui/workers.py index de3846a..1ee6141 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -5,6 +5,7 @@ from __future__ import annotations import datetime as _dt import re import threading +import time from .. import image_studio, image_studio_export, image_studio_generation, image_studio_images from .widgets import * @@ -110,22 +111,103 @@ class ImageStudioPullImagesWorker(BaseWorker): class ImageStudioDownloadOriginalWorker(BaseWorker): """Download one remote original image into the project pool.""" - def __init__(self, asset_id, *, db_path=None, config=None, open_after=False): + def __init__( + self, + asset_id, + *, + db_path=None, + config=None, + open_after=False, + max_retries=2, + retry_delays=(1, 2), + ): super().__init__() self.asset_id = int(asset_id) self.db_path = db_path self.config = config self.open_after = bool(open_after) + self.max_retries = max(0, int(max_retries or 0)) + self.retry_delays = tuple(float(delay) for delay in (retry_delays or ())) def execute(self): - self.log.emit("[AI工场] 下载原图:开始") - asset = image_studio_images.download_original_asset( - self.asset_id, - path=self.db_path, - config=self.config, - ) - self.log.emit("[AI工场] 下载原图:成功") - return {"asset": asset, "open_after": self.open_after} + attempts = self.max_retries + 1 + for attempt in range(1, attempts + 1): + if self.should_cancel(): + return {"asset_id": self.asset_id, "cancelled": True} + self.progress.emit( + { + "asset_id": self.asset_id, + "state": "start", + "attempt": attempt, + "attempts": attempts, + } + ) + self.log.emit(f"[AI工场] 下载蝦皮原主图 #{self.asset_id}:开始") + try: + asset = image_studio_images.download_original_asset( + self.asset_id, + path=self.db_path, + config=self.config, + ) + except Exception: + retry = attempt + if attempt >= attempts: + self.progress.emit( + { + "asset_id": self.asset_id, + "state": "failed", + "attempt": attempt, + "attempts": attempts, + } + ) + self.log.emit(f"[AI工场] 下载蝦皮原主图 #{self.asset_id}:最终失败") + return { + "ok": False, + "asset_id": self.asset_id, + "open_after": self.open_after, + "error": "蝦皮原主图下载失败,请稍后再次点击图片重试。", + } + delay = self._retry_delay(retry) + self.progress.emit( + { + "asset_id": self.asset_id, + "state": "retry", + "retry": retry, + "max_retries": self.max_retries, + "delay_seconds": delay, + } + ) + self.log.emit( + f"[AI工场] 下载蝦皮原主图 #{self.asset_id}:失败,准备重试 {retry}/{self.max_retries}" + ) + if not self._wait_for_retry(delay): + return {"asset_id": self.asset_id, "cancelled": True} + continue + self.progress.emit( + { + "asset_id": self.asset_id, + "state": "success", + "attempt": attempt, + "attempts": attempts, + } + ) + self.log.emit(f"[AI工场] 下载蝦皮原主图 #{self.asset_id}:成功") + return {"asset": asset, "asset_id": self.asset_id, "open_after": self.open_after} + return {"asset_id": self.asset_id, "cancelled": True} + + def _retry_delay(self, retry): + if retry <= 0: + return 0.0 + index = min(retry - 1, len(self.retry_delays) - 1) + return self.retry_delays[index] if index >= 0 else 0.0 + + def _wait_for_retry(self, delay_seconds): + deadline = time.monotonic() + max(0.0, float(delay_seconds or 0)) + while time.monotonic() < deadline: + if self.should_cancel(): + return False + time.sleep(min(0.1, max(0.0, deadline - time.monotonic()))) + return not self.should_cancel() class ImageStudioGenerateJobsWorker(BaseWorker): diff --git a/docs/tasks/T-608.md b/docs/tasks/T-608.md index d0df225..81c8e7b 100644 --- a/docs/tasks/T-608.md +++ b/docs/tasks/T-608.md @@ -3,7 +3,7 @@ id: T-608 title: AI工场主图重新拉取确认、非阻塞原图下载与区域比例优化 phase: 7 deps: [T-607] -status: TODO +status: DONE created: 2026-07-11 --- @@ -120,4 +120,7 @@ git diff --check ## 执行记录 -- 待执行。 +- 2026-07-11:在 `ImageStudioTab` 增加本地原图计数与重新拉取确认;同一项目存在原图下载/排队时,拦截重新拉取和软删除。 +- 2026-07-11:将单张原图完整下载从通用全局 worker 拆为专用内存队列;同图去重、最多并发 2、失败自动重试 2 次(最多 3 次尝试),仅刷新原图卡片和原图区状态,不再禁用整个 AI工场。 +- 2026-07-11:增加下载中/等待/重试/失败状态、项目切换隔离、关闭时取消未开始队列和已启动 worker 的取消请求;压缩原图区与商品列表尺寸,将空间让给照片池。 +- 2026-07-11:新增/更新 GUI 与 worker 回归测试。干净 worktree 验证通过:`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`(386 项)、`git diff --check`。主工作区另有未提交默认提示词文件变更,导致其全量测试中 3 项默认提示词断言失败,未纳入本任务修改。 diff --git a/tests/test_gui.py b/tests/test_gui.py index ffbd1e8..0f0e757 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -693,7 +693,10 @@ class GuiTests(TempDirMixin, unittest.TestCase): ) self.assertEqual(9, len(thumbnail_loader.submissions)) self.assertEqual(0, tab.pool_grid.count()) - self.assertEqual(QSize(78, 92), tab.original_grid.gridSize()) + self.assertEqual(QSize(58, 78), tab.original_grid.gridSize()) + self.assertEqual(94, tab.original_grid.minimumHeight()) + self.assertEqual(110, tab.original_grid.maximumHeight()) + self.assertEqual(150, tab.project_table.minimumWidth()) self.assertEqual(QSize(108, 124), tab.pool_grid.gridSize()) self.assertGreaterEqual(tab.prompt_edit.minimumHeight(), 210) @@ -1029,6 +1032,215 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_image_studio_repull_confirms_when_local_originals_exist(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) + project = image_studio.create_or_get_project( + account_alias="alias-a", + account_slug="alias_a", + item_id="51100639510", + path=cfg["db_path"], + ) + original = image_studio.sync_original_asset_urls( + project.id, + [{"index": 1, "src": "https://susercontent.com/main-1.jpg"}], + path=cfg["db_path"], + )[0] + image_studio.update_asset_local_path( + original.id, + self.write_test_image(os.path.join(temp_dir, "main-1.jpg")), + path=cfg["db_path"], + ) + tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"]) + self.addCleanup(tab.close) + tab._select_project(project.id) + confirmations = [] + tab._confirm = lambda title, text: confirmations.append((title, text)) or False + + with mock.patch("app.gui.tabs.image_studio.ImageStudioPullImagesWorker") as worker_factory: + tab.pull_main_images() + + worker_factory.assert_not_called() + self.assertEqual("重新拉取蝦皮主图", confirmations[0][0]) + self.assertIn("本地已保存 1 张蝦皮原主图", confirmations[0][1]) + self.assertIn("不会删除本地图片、生成记录或终选记录", confirmations[0][1]) + + self.assert_removed(temp_dir) + + def test_image_studio_original_download_queue_is_local_and_nonblocking(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + db.init_db(cfg["db_path"]) + accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) + project = image_studio.create_or_get_project( + account_alias="alias-a", + account_slug="alias_a", + item_id="51100639510", + path=cfg["db_path"], + ) + originals = image_studio.sync_original_asset_urls( + project.id, + [ + {"index": index, "src": f"https://susercontent.com/main-{index}.jpg"} + for index in range(1, 4) + ], + path=cfg["db_path"], + ) + tab = ImageStudioTab( + config=cfg, + db_path=cfg["db_path"], + thumbnail_loader=FakeThumbnailLoader(), + ) + self.addCleanup(tab.close) + tab._select_project(project.id) + messages = [] + tab._message = lambda title, text: messages.append((title, text)) + + class FakeDownloadWorker: + instances = [] + + def __init__(self, asset_id, **kwargs): + self.asset_id = asset_id + self.open_after = kwargs.get("open_after", False) + self.progress = DummySignal() + self.log = DummySignal() + self.finished = DummySignal() + self.cancelled = DummySignal() + self.cancel_requested = False + FakeDownloadWorker.instances.append(self) + + def cancel(self): + self.cancel_requested = True + + threads = [] + + def fake_run_worker(*args, **kwargs): + thread = FakeThread() + threads.append(thread) + return thread + + with mock.patch( + "app.gui.tabs.image_studio.ImageStudioDownloadOriginalWorker", + FakeDownloadWorker, + ), mock.patch("app.gui.tabs.image_studio.run_worker", side_effect=fake_run_worker): + for original in originals: + tab._ensure_original_in_pool(original) + + self.assertEqual(2, len(FakeDownloadWorker.instances)) + self.assertEqual(1, len(tab._original_download_queue)) + self.assertFalse(tab._operation_running) + self.assertTrue(tab.prompt_edit.isEnabled()) + self.assertTrue(tab.pool_grid.isEnabled()) + self.assertFalse(tab.delete_project_button.isEnabled()) + self.assertIn("等待下载", tab.original_grid.item(2).text()) + self.assertIn("正在下载蝦皮原主图 #1、#2(2/2)", tab.original_download_label.text()) + + tab.delete_current_project() + tab.pull_main_images() + self.assertEqual("暂不能删除项目", messages[0][0]) + self.assertEqual("原图下载尚未完成", messages[1][0]) + + first_worker = FakeDownloadWorker.instances[0] + first_worker.progress.emit( + {"state": "retry", "retry": 1, "max_retries": 2} + ) + self.assertIn("正在重试 1/2", tab.original_download_label.text()) + self.assertIn("重试 1/2", tab.original_grid.item(0).text()) + + local_path = self.write_test_image(os.path.join(temp_dir, "downloaded.jpg")) + downloaded = image_studio.update_asset_local_path( + originals[0].id, + local_path, + path=cfg["db_path"], + ) + first_worker.finished.emit({"asset": downloaded, "open_after": False}) + self.assertEqual(originals[0].id, tab.selected_source_asset_id) + self.assertEqual(1, tab.pool_grid.count()) + + threads[0].finished.emit() + self.assertEqual(3, len(FakeDownloadWorker.instances)) + self.assertEqual(0, len(tab._original_download_queue)) + self.assertEqual(2, len(tab._original_downloads)) + + third_worker = FakeDownloadWorker.instances[2] + third_worker.finished.emit({"ok": False, "error": "英文网络错误"}) + self.assertIn("下载失败", tab.original_grid.item(2).text()) + self.assertIn("可再次点击图片重试", tab.original_download_label.text()) + self.assertFalse(any("英文网络错误" in text for _title, text in messages)) + + tab.close() + self.assertTrue(FakeDownloadWorker.instances[1].cancel_requested) + self.assertTrue(FakeDownloadWorker.instances[2].cancel_requested) + for thread in threads[1:]: + thread.finished.emit() + + self.assert_removed(temp_dir) + + def test_image_studio_original_download_result_does_not_refresh_other_project(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + db.init_db(cfg["db_path"]) + first = image_studio.create_or_get_project( + account_alias="alias-a", + account_slug="alias_a", + item_id="51100639510", + path=cfg["db_path"], + ) + second = image_studio.create_or_get_project( + account_alias="alias-a", + account_slug="alias_a", + item_id="51100639511", + path=cfg["db_path"], + ) + original = image_studio.sync_original_asset_urls( + first.id, + [{"index": 1, "src": "https://susercontent.com/main-1.jpg"}], + path=cfg["db_path"], + )[0] + tab = ImageStudioTab( + config=cfg, + db_path=cfg["db_path"], + thumbnail_loader=FakeThumbnailLoader(), + ) + self.addCleanup(tab.close) + tab._select_project(first.id) + + class FakeDownloadWorker: + def __init__(self, asset_id, **kwargs): + self.asset_id = asset_id + self.open_after = kwargs.get("open_after", False) + self.progress = DummySignal() + self.log = DummySignal() + self.finished = DummySignal() + self.cancelled = DummySignal() + + def cancel(self): + return None + + thread = FakeThread() + with mock.patch( + "app.gui.tabs.image_studio.ImageStudioDownloadOriginalWorker", + FakeDownloadWorker, + ), mock.patch("app.gui.tabs.image_studio.run_worker", return_value=thread): + tab._ensure_original_in_pool(original) + tab._select_project(second.id) + downloaded = image_studio.update_asset_local_path( + original.id, + self.write_test_image(os.path.join(temp_dir, "downloaded.jpg")), + path=cfg["db_path"], + ) + next(iter(tab._original_downloads.values()))["worker"].finished.emit( + {"asset": downloaded, "open_after": False} + ) + + self.assertEqual(second.id, tab.current_project.id) + self.assertEqual(0, tab.pool_grid.count()) + self.assertEqual("", tab.original_download_label.text()) + thread.finished.emit() + + self.assert_removed(temp_dir) + def test_image_studio_event_log_hides_provider_urls(self): message = gui_workers._format_image_studio_event( { diff --git a/tests/test_workers.py b/tests/test_workers.py index a6f31b7..0434c60 100644 --- a/tests/test_workers.py +++ b/tests/test_workers.py @@ -1,5 +1,7 @@ import os import unittest +from types import SimpleNamespace +from unittest import mock os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") @@ -14,6 +16,7 @@ from PySide6.QtCore import QEventLoop, QTimer from PySide6.QtWidgets import QApplication from app.workers import BaseWorker, run_worker +from app.gui.workers import ImageStudioDownloadOriginalWorker class DemoWorker(BaseWorker): @@ -112,6 +115,56 @@ class WorkerTests(unittest.TestCase): with self.assertRaises(TypeError): run_worker(object(), start=False) + def test_image_studio_original_download_retries_twice_without_raw_error(self): + worker = ImageStudioDownloadOriginalWorker( + 12, + max_retries=2, + retry_delays=(0, 0), + ) + progress = [] + logs = [] + worker.progress.connect(lambda payload: progress.append(dict(payload))) + worker.log.connect(logs.append) + successful_asset = SimpleNamespace(id=12) + + with mock.patch( + "app.gui.workers.image_studio_images.download_original_asset", + side_effect=[RuntimeError("first"), RuntimeError("second"), successful_asset], + ) as download: + summary = worker.execute() + + self.assertEqual(3, download.call_count) + self.assertIs(successful_asset, summary["asset"]) + retries = [payload for payload in progress if payload.get("state") == "retry"] + self.assertEqual( + [ + {"asset_id": 12, "state": "retry", "retry": 1, "max_retries": 2, "delay_seconds": 0.0}, + {"asset_id": 12, "state": "retry", "retry": 2, "max_retries": 2, "delay_seconds": 0.0}, + ], + retries, + ) + self.assertTrue(any("重试 1/2" in message for message in logs)) + self.assertTrue(any("重试 2/2" in message for message in logs)) + self.assertFalse(any("first" in message or "second" in message for message in logs)) + + def test_image_studio_original_download_returns_chinese_final_failure(self): + worker = ImageStudioDownloadOriginalWorker( + 12, + max_retries=2, + retry_delays=(0, 0), + ) + + with mock.patch( + "app.gui.workers.image_studio_images.download_original_asset", + side_effect=RuntimeError("https://example.invalid/private"), + ) as download: + summary = worker.execute() + + self.assertEqual(3, download.call_count) + self.assertFalse(summary["ok"]) + self.assertEqual("蝦皮原主图下载失败,请稍后再次点击图片重试。", summary["error"]) + self.assertNotIn("https://", summary["error"]) + if __name__ == "__main__": unittest.main()