feat(product-suite): compact controls and remember settings

This commit is contained in:
chengma
2026-07-14 15:16:09 +08:00
parent 17bb7521fd
commit 21d2dd5253
10 changed files with 431 additions and 47 deletions
+29
View File
@@ -112,6 +112,14 @@ DEFAULT_CONFIG = {
"dry_run": False, "dry_run": False,
"max_parallel_accounts": 1, "max_parallel_accounts": 1,
}, },
"product_suite": {
"last_settings": {
"platform": "Shopee",
"country": "中国台湾",
"language": "繁体中文",
"ratio": "1:1",
},
},
} }
DEFAULT_AI_MODELS_CONFIG = { DEFAULT_AI_MODELS_CONFIG = {
@@ -426,9 +434,22 @@ def _normalize_config_values(config, migrate_old_cmhub_connect_timeout=False):
9999, 9999,
DEFAULT_CONFIG["shopee_update"]["max_items_per_run"], DEFAULT_CONFIG["shopee_update"]["max_items_per_run"],
) )
suite = config.get("product_suite")
if not isinstance(suite, dict):
suite = {}
config["product_suite"] = suite
suite["last_settings"] = _normalize_product_suite_last_settings(
suite.get("last_settings")
)
return config return config
def _normalize_product_suite_last_settings(value):
from . import product_suite
return product_suite.last_suite_settings(value)
def _clamp_int(value, minimum, maximum, default): def _clamp_int(value, minimum, maximum, default):
try: try:
number = int(value) number = int(value)
@@ -673,6 +694,14 @@ def ai_config(config=None) -> dict:
return copy.deepcopy(_config_or_load(config).get("ai", DEFAULT_CONFIG["ai"])) return copy.deepcopy(_config_or_load(config).get("ai", DEFAULT_CONFIG["ai"]))
def product_suite_last_settings(config=None) -> dict:
cfg = _config_or_load(config)
suite = cfg.get("product_suite", {})
if not isinstance(suite, dict):
suite = {}
return _normalize_product_suite_last_settings(suite.get("last_settings"))
def normalize_generate_mode(value=None, generate_cover=None) -> str: def normalize_generate_mode(value=None, generate_cover=None) -> str:
text = str(value or "").strip().lower() text = str(value or "").strip().lower()
if text in AI_GENERATE_MODES: if text in AI_GENERATE_MODES:
+145 -35
View File
@@ -30,6 +30,7 @@ from PySide6.QtWidgets import (
QProgressBar, QProgressBar,
QPushButton, QPushButton,
QScrollArea, QScrollArea,
QSizePolicy,
QSplitter, QSplitter,
QTabBar, QTabBar,
QToolButton, QToolButton,
@@ -381,9 +382,9 @@ class SuiteTaskState:
class ProductSuiteTab(QWidget): class ProductSuiteTab(QWidget):
"""Native PySide6 product-suite UI backed by image_studio services.""" """Native PySide6 product-suite UI backed by image_studio services."""
PLATFORM_OPTIONS = ("Shopee", "Lazada", "TikTok Shop", "Amazon") PLATFORM_OPTIONS = product_suite.PLATFORMS
COUNTRY_OPTIONS = ("中国台湾", "新加坡", "马来西亚", "菲律宾", "泰国", "越南") COUNTRY_OPTIONS = product_suite.COUNTRIES
LANGUAGE_OPTIONS = ("繁体中文", "简体中文", "英文", "泰文", "越南文") LANGUAGE_OPTIONS = product_suite.LANGUAGES
def __init__( def __init__(
self, self,
@@ -443,6 +444,31 @@ class ProductSuiteTab(QWidget):
self.task_tabs.setTabsClosable(True) self.task_tabs.setTabsClosable(True)
self.task_tabs.setMovable(True) self.task_tabs.setMovable(True)
self.task_tabs.setExpanding(False) self.task_tabs.setExpanding(False)
self.task_tabs.setFixedHeight(36)
self.task_tabs.setStyleSheet(
"""
QTabBar#suiteTaskTabs::tab {
min-width: 96px;
min-height: 26px;
max-height: 26px;
padding: 3px 10px;
margin-right: 4px;
border: 1px solid #c9d1d9;
border-bottom-color: #b8c0ca;
background: #f4f6f8;
color: #24292f;
}
QTabBar#suiteTaskTabs::tab:selected {
background: #ffffff;
border-color: #687785;
border-bottom-color: #ffffff;
font-weight: 600;
}
QTabBar#suiteTaskTabs::tab:hover:!selected {
background: #eaf2ff;
}
"""
)
layout.addWidget(self.task_tabs, 1) layout.addWidget(self.task_tabs, 1)
self.add_task_button = QToolButton() self.add_task_button = QToolButton()
self.add_task_button.setObjectName("suiteAddTaskButton") self.add_task_button.setObjectName("suiteAddTaskButton")
@@ -457,20 +483,38 @@ class ProductSuiteTab(QWidget):
frame = QFrame() frame = QFrame()
frame.setObjectName("suiteContextBar") frame.setObjectName("suiteContextBar")
layout = QHBoxLayout(frame) layout = QHBoxLayout(frame)
self.context_bar_layout = layout
layout.setContentsMargins(10, 7, 10, 7) layout.setContentsMargins(10, 7, 10, 7)
layout.setSpacing(8) layout.setSpacing(8)
self.history_button = QPushButton("历史生成")
self.history_button.setObjectName("suiteHistoryButton")
self.history_button.setCheckable(True)
layout.addWidget(self.history_button)
self.open_folder_button = QPushButton("打开结果文件夹")
self.open_folder_button.setObjectName("suiteOpenFolderButton")
layout.addWidget(self.open_folder_button)
self.add_images_button = QPushButton("添加图片")
self.add_images_button.setObjectName("suiteAddImagesButton")
layout.addWidget(self.add_images_button)
layout.addStretch(1)
layout.addWidget(QLabel("账号")) layout.addWidget(QLabel("账号"))
self.account_combo = QComboBox() self.account_combo = QComboBox()
self.account_combo.setObjectName("suiteAccountCombo") self.account_combo.setObjectName("suiteAccountCombo")
self.account_combo.setMinimumWidth(180) self.account_combo.setMinimumWidth(120)
self.account_combo.setMaximumWidth(160)
self.account_combo.setMinimumContentsLength(10)
layout.addWidget(self.account_combo) layout.addWidget(self.account_combo)
layout.addWidget(QLabel("商品ID")) layout.addWidget(QLabel("商品ID"))
self.item_id_edit = QLineEdit() self.item_id_edit = QLineEdit()
self.item_id_edit.setObjectName("suiteItemIdEdit") self.item_id_edit.setObjectName("suiteItemIdEdit")
self.item_id_edit.setPlaceholderText("请输入蝦皮商品ID") self.item_id_edit.setPlaceholderText("输入商品ID")
self.item_id_edit.setMinimumWidth(150) item_width = max(
120,
min(140, self.item_id_edit.fontMetrics().horizontalAdvance("0" * 13) + 30),
)
self.item_id_edit.setMinimumWidth(item_width)
self.item_id_edit.setMaximumWidth(item_width)
layout.addWidget(self.item_id_edit) layout.addWidget(self.item_id_edit)
layout.addStretch(1)
self.pull_button = QPushButton("拉取蝦皮主图") self.pull_button = QPushButton("拉取蝦皮主图")
self.pull_button.setObjectName("suitePullButton") self.pull_button.setObjectName("suitePullButton")
layout.addWidget(self.pull_button) layout.addWidget(self.pull_button)
@@ -478,6 +522,7 @@ class ProductSuiteTab(QWidget):
def _build_config_panel(self): def _build_config_panel(self):
panel = QWidget() panel = QWidget()
panel.setMinimumWidth(400)
layout = QVBoxLayout(panel) layout = QVBoxLayout(panel)
layout.setContentsMargins(0, 0, 4, 0) layout.setContentsMargins(0, 0, 4, 0)
layout.setSpacing(7) layout.setSpacing(7)
@@ -524,9 +569,6 @@ class ProductSuiteTab(QWidget):
self.original_count_label.setStyleSheet("color: #6b7280;") self.original_count_label.setStyleSheet("color: #6b7280;")
title_row.addWidget(self.original_count_label) title_row.addWidget(self.original_count_label)
title_row.addStretch(1) title_row.addStretch(1)
self.add_images_button = QPushButton("添加图片")
self.add_images_button.setObjectName("suiteAddImagesButton")
title_row.addWidget(self.add_images_button)
layout.addLayout(title_row) layout.addLayout(title_row)
self.original_list = ProductOriginalList() self.original_list = ProductOriginalList()
self.original_list.setFixedHeight(210) self.original_list.setFixedHeight(210)
@@ -541,28 +583,69 @@ class ProductSuiteTab(QWidget):
title.setStyleSheet("font-weight: 600;") title.setStyleSheet("font-weight: 600;")
layout.addWidget(title) layout.addWidget(title)
grid = QGridLayout() grid = QGridLayout()
self.settings_grid = grid
grid.setContentsMargins(0, 0, 0, 0) grid.setContentsMargins(0, 0, 0, 0)
grid.setSpacing(6) grid.setHorizontalSpacing(6)
self.platform_combo = self._prefixed_combo("suitePlatformCombo", "平台", self.PLATFORM_OPTIONS) grid.setVerticalSpacing(3)
self.country_combo = self._prefixed_combo("suiteCountryCombo", "国家", self.COUNTRY_OPTIONS) settings = (
self.language_combo = self._prefixed_combo("suiteLanguageCombo", "语言", self.LANGUAGE_OPTIONS) (
self.ratio_combo = self._prefixed_combo("suiteRatioCombo", "比例", product_suite.RATIOS) "platform_label",
grid.addWidget(self.platform_combo, 0, 0) "platform_combo",
grid.addWidget(self.country_combo, 0, 1) "平台",
grid.addWidget(self.language_combo, 1, 0) "suitePlatformLabel",
grid.addWidget(self.ratio_combo, 1, 1) "suitePlatformCombo",
self.PLATFORM_OPTIONS,
),
(
"country_label",
"country_combo",
"站点",
"suiteCountryLabel",
"suiteCountryCombo",
self.COUNTRY_OPTIONS,
),
(
"language_label",
"language_combo",
"语言",
"suiteLanguageLabel",
"suiteLanguageCombo",
self.LANGUAGE_OPTIONS,
),
(
"ratio_label",
"ratio_combo",
"比例",
"suiteRatioLabel",
"suiteRatioCombo",
product_suite.RATIOS,
),
)
for column, setting in enumerate(settings):
label_attr, combo_attr, text, label_name, combo_name, values = setting
label = QLabel(text)
label.setObjectName(label_name)
setattr(self, label_attr, label)
combo = self._value_combo(combo_name, text, values)
setattr(self, combo_attr, combo)
grid.addWidget(label, 0, column)
grid.addWidget(combo, 1, column)
grid.setColumnStretch(column, 1)
layout.addLayout(grid) layout.addLayout(grid)
self.per_image_checkbox = QCheckBox("每张上传图分别作为主图生成") self.per_image_checkbox = QCheckBox("每张上传图分别作为主图生成")
self.per_image_checkbox.setObjectName("suitePerImageCheckbox") self.per_image_checkbox.setObjectName("suitePerImageCheckbox")
layout.addWidget(self.per_image_checkbox) layout.addWidget(self.per_image_checkbox)
return frame return frame
def _prefixed_combo(self, object_name, prefix, values): def _value_combo(self, object_name, label, values):
combo = QComboBox() combo = QComboBox()
combo.setObjectName(object_name) combo.setObjectName(object_name)
combo.setToolTip("%s设置" % prefix) combo.setToolTip("%s设置" % label)
combo.setAccessibleName("%s设置" % label)
combo.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
for value in values: for value in values:
combo.addItem("%s %s" % (prefix, value), value) combo.addItem(value, value)
combo.setItemData(combo.count() - 1, value, Qt.ToolTipRole)
return combo return combo
def _build_prompt_section(self): def _build_prompt_section(self):
@@ -628,6 +711,7 @@ class ProductSuiteTab(QWidget):
layout.setContentsMargins(4, 0, 0, 0) layout.setContentsMargins(4, 0, 0, 0)
layout.setSpacing(7) layout.setSpacing(7)
toolbar = QHBoxLayout() toolbar = QHBoxLayout()
self.results_toolbar_layout = toolbar
title = QLabel("生成结果") title = QLabel("生成结果")
title.setStyleSheet("font-weight: 600; font-size: 15px;") title.setStyleSheet("font-weight: 600; font-size: 15px;")
toolbar.addWidget(title) toolbar.addWidget(title)
@@ -642,13 +726,6 @@ class ProductSuiteTab(QWidget):
self.undo_button.setObjectName("suiteUndoButton") self.undo_button.setObjectName("suiteUndoButton")
self.undo_button.hide() self.undo_button.hide()
toolbar.addWidget(self.undo_button) toolbar.addWidget(self.undo_button)
self.history_button = QPushButton("历史生成")
self.history_button.setObjectName("suiteHistoryButton")
self.history_button.setCheckable(True)
toolbar.addWidget(self.history_button)
self.open_folder_button = QPushButton("打开文件夹")
self.open_folder_button.setObjectName("suiteOpenFolderButton")
toolbar.addWidget(self.open_folder_button)
self.more_button = QToolButton() self.more_button = QToolButton()
self.more_button.setText("⋯") self.more_button.setText("⋯")
self.more_button.setToolTip("更多操作") self.more_button.setToolTip("更多操作")
@@ -750,9 +827,12 @@ class ProductSuiteTab(QWidget):
try: try:
self.account_combo.clear() self.account_combo.clear()
for account in self.accounts: for account in self.accounts:
self.account_combo.addItem( text = "%s(%s)" % (account.account_name, account.alias)
"%s(%s)" % (account.account_name, account.alias), self.account_combo.addItem(text, account.alias)
account.alias, self.account_combo.setItemData(
self.account_combo.count() - 1,
text,
Qt.ToolTipRole,
) )
if not self.accounts: if not self.accounts:
self.account_combo.addItem("暂无账号,请先到④账号管理添加", "") self.account_combo.addItem("暂无账号,请先到④账号管理添加", "")
@@ -761,17 +841,21 @@ class ProductSuiteTab(QWidget):
self.account_combo.setCurrentIndex(index) self.account_combo.setCurrentIndex(index)
finally: finally:
self._loading = False self._loading = False
self._update_account_tooltip()
def add_task(self, checked=False, inherit=True): def add_task(self, checked=False, inherit=True):
source = self._displayed_state if inherit else None source = self._displayed_state if inherit else None
initial_settings = (
source.settings
if source is not None
else appconfig.product_suite_last_settings(self.config)
)
state = SuiteTaskState( state = SuiteTaskState(
key=self._next_key, key=self._next_key,
serial=self._next_serial, serial=self._next_serial,
account_alias=(source.account_alias if source is not None else ""), account_alias=(source.account_alias if source is not None else ""),
prompt=(source.prompt if source is not None else ""), prompt=(source.prompt if source is not None else ""),
settings=product_suite.normalize_suite_settings( settings=product_suite.normalize_suite_settings(initial_settings),
source.settings if source is not None else None
),
) )
if not state.account_alias and self.accounts: if not state.account_alias and self.accounts:
state.account_alias = self.accounts[0].alias state.account_alias = self.accounts[0].alias
@@ -877,6 +961,7 @@ class ProductSuiteTab(QWidget):
return product_suite.normalize_suite_settings(settings) return product_suite.normalize_suite_settings(settings)
def _on_account_changed(self, index): def _on_account_changed(self, index):
self._update_account_tooltip()
if self._loading or self._displayed_state is None: if self._loading or self._displayed_state is None:
return return
state = self._displayed_state state = self._displayed_state
@@ -896,6 +981,9 @@ class ProductSuiteTab(QWidget):
state.account_alias = alias state.account_alias = alias
self._update_context_actions(state) self._update_context_actions(state)
def _update_account_tooltip(self):
self.account_combo.setToolTip(self.account_combo.currentText())
def _on_item_finished(self): def _on_item_finished(self):
if self._loading or self._displayed_state is None: if self._loading or self._displayed_state is None:
return return
@@ -1000,9 +1088,31 @@ class ProductSuiteTab(QWidget):
return return
state = self._displayed_state state = self._displayed_state
state.settings = self._settings_from_controls() state.settings = self._settings_from_controls()
self._persist_last_settings(state.settings)
self._persist_state(state) self._persist_state(state)
self._refresh_totals(state) self._refresh_totals(state)
def _persist_last_settings(self, settings):
last_settings = product_suite.last_suite_settings(settings)
try:
if os.path.exists(self.config_path):
saved = appconfig.update_config(
{"product_suite": {"last_settings": last_settings}},
path=self.config_path,
)
else:
base = dict(self.config)
suite = base.get("product_suite", {})
suite = dict(suite) if isinstance(suite, dict) else {}
suite["last_settings"] = last_settings
base["product_suite"] = suite
saved = appconfig.save_config(base, path=self.config_path)
except Exception as exc:
self._status("商品套图最近设置保存失败:%s" % _user_error(exc), "danger")
return
self.config.clear()
self.config.update(saved)
def _on_prompt_changed(self): def _on_prompt_changed(self):
if self._loading or self._displayed_state is None: if self._loading or self._displayed_state is None:
return return
+18 -5
View File
@@ -9,7 +9,11 @@ FIXED_CATEGORIES = ("白底图", "场景图", "卖点图")
DEFAULT_CATEGORY_COUNTS = OrderedDict( DEFAULT_CATEGORY_COUNTS = OrderedDict(
(("白底图", 1), ("场景图", 2), ("卖点图", 2)) (("白底图", 1), ("场景图", 2), ("卖点图", 2))
) )
PLATFORMS = ("Shopee", "Lazada", "TikTok Shop", "Amazon")
COUNTRIES = ("中国台湾", "新加坡", "马来西亚", "菲律宾", "泰国", "越南")
LANGUAGES = ("繁体中文", "简体中文", "英文", "泰文", "越南文")
RATIOS = ("1:1", "3:4", "4:3", "16:9", "9:16") RATIOS = ("1:1", "3:4", "4:3", "16:9", "9:16")
LAST_SETTING_KEYS = ("platform", "country", "language", "ratio")
MAX_CATEGORY_NAME_LENGTH = 10 MAX_CATEGORY_NAME_LENGTH = 10
MAX_GENERATION_COUNT_WITHOUT_CONFIRM = 16 MAX_GENERATION_COUNT_WITHOUT_CONFIRM = 16
@@ -29,11 +33,10 @@ def default_suite_settings():
def normalize_suite_settings(value=None): def normalize_suite_settings(value=None):
raw = dict(value or {}) if isinstance(value, dict) else {} raw = dict(value or {}) if isinstance(value, dict) else {}
normalized = default_suite_settings() normalized = default_suite_settings()
normalized["platform"] = str(raw.get("platform") or "Shopee") normalized["platform"] = _choice(raw.get("platform"), PLATFORMS, "Shopee")
normalized["country"] = str(raw.get("country") or "中国台湾") normalized["country"] = _choice(raw.get("country"), COUNTRIES, "中国台湾")
normalized["language"] = str(raw.get("language") or "繁体中文") normalized["language"] = _choice(raw.get("language"), LANGUAGES, "繁体中文")
ratio = str(raw.get("ratio") or "1:1") normalized["ratio"] = _choice(raw.get("ratio"), RATIOS, "1:1")
normalized["ratio"] = ratio if ratio in RATIOS else "1:1"
normalized["per_image_primary"] = bool(raw.get("per_image_primary", False)) normalized["per_image_primary"] = bool(raw.get("per_image_primary", False))
raw_categories = raw.get("categories") if isinstance(raw.get("categories"), dict) else {} raw_categories = raw.get("categories") if isinstance(raw.get("categories"), dict) else {}
@@ -56,6 +59,11 @@ def normalize_suite_settings(value=None):
return normalized return normalized
def last_suite_settings(value=None):
normalized = normalize_suite_settings(value)
return {key: normalized[key] for key in LAST_SETTING_KEYS}
def suite_name_error(name, existing=None, old_name=""): def suite_name_error(name, existing=None, old_name=""):
value = str(name or "") value = str(name or "")
if not value.strip(): if not value.strip():
@@ -146,3 +154,8 @@ def _count(value):
return max(0, int(value or 0)) return max(0, int(value or 0))
except (TypeError, ValueError): except (TypeError, ValueError):
return 0 return 0
def _choice(value, choices, default):
text = str(value or "").strip()
return text if text in choices else default
+10
View File
@@ -135,6 +135,14 @@ T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/data`,源
"max_items_per_run": 1, "max_items_per_run": 1,
"dry_run": false, "dry_run": false,
"max_parallel_accounts": 1 "max_parallel_accounts": 1
},
"product_suite": {
"last_settings": {
"platform": "Shopee",
"country": "中国台湾",
"language": "繁体中文",
"ratio": "1:1"
}
} }
} }
``` ```
@@ -161,6 +169,8 @@ T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/data`,源
该段不是替代 ③ 确认弹窗的常驻授权;③「开始更新」仍必须弹窗确认,用户点是后才执行。`dry_run=true` 时不会真实提交;`dry_run=false` 时以③确认弹窗作为线上提交前的唯一显式确认边界。普通正式更新不读取 `test_item_id` 做阻断。旧配置中的真实提交、封面更新、成功关页等开关只作迁移兼容读取,保存后不再写回。 该段不是替代 ③ 确认弹窗的常驻授权;③「开始更新」仍必须弹窗确认,用户点是后才执行。`dry_run=true` 时不会真实提交;`dry_run=false` 时以③确认弹窗作为线上提交前的唯一显式确认边界。普通正式更新不读取 `test_item_id` 做阻断。旧配置中的真实提交、封面更新、成功关页等开关只作迁移兼容读取,保存后不再写回。
`product_suite.last_settings` 只保存⑥「商品套图」最近一次选择的平台、站点、语言和比例,作为软件重启后第一个未绑定商品任务的默认值。已有账号+商品 ID 项目仍以 SQLite `image_studio_projects.suite_settings_json` 为准;同一次运行中新任务优先继承当前任务;绑定已有项目后再由项目设置覆盖最近默认。旧配置缺少该段或值不在当前选项集合时,分别回退到 `Shopee / 中国台湾 / 繁体中文 / 1:1`。该段不保存账号、商品 ID、提示词、图片路径或密钥。
### 5.1b AI 模型清单 `data/config/ai_models.json` ### 5.1b AI 模型清单 `data/config/ai_models.json`
模型定义清单("有哪些模型"),与 `config.json` 的 `ai` 段("选了哪个 + 全局参数")职责分开。该文件只用于内部兼容 `backend=direct`;普通用户默认 `backend=cmhub`,生文/生图使用 cmhub 别名,不读取此文件。 模型定义清单("有哪些模型"),与 `config.json` 的 `ai` 段("选了哪个 + 全局参数")职责分开。该文件只用于内部兼容 `backend=direct`;普通用户默认 `backend=cmhub`,生文/生图使用 cmhub 别名,不读取此文件。
+2 -1
View File
@@ -32,6 +32,7 @@ cdp_ready_timeout(config=None) -> int
ai_config(config=None) -> dict # default_text_model/default_image_model/generate_mode/generate_cover/ ai_config(config=None) -> dict # default_text_model/default_image_model/generate_mode/generate_cover/
# title_concurrency/image_concurrency/retry/jpg_quality/ # title_concurrency/image_concurrency/retry/jpg_quality/
# resolution/resolution_timeouts # resolution/resolution_timeouts
product_suite_last_settings(config=None) -> dict # 最近的平台/站点/语言/比例;非法值回退默认
ai_backend(config=None) -> str # 默认 cmhub;direct 仅内部兼容/手工回滚 ai_backend(config=None) -> str # 默认 cmhub;direct 仅内部兼容/手工回滚
cmhub_config(config=None) -> dict # base_url/title_alias/image_alias/connect_timeout/download_with_curl cmhub_config(config=None) -> dict # base_url/title_alias/image_alias/connect_timeout/download_with_curl
normalize_cmhub_base_url(base_url) -> str # 规整为 cmhub 网关根:scheme+host(+port) normalize_cmhub_base_url(base_url) -> str # 规整为 cmhub 网关根:scheme+host(+port)
@@ -39,7 +40,7 @@ cmhub_request_url(base_url, endpoint) -> str # 先规整 base_url,再拼 /ap
response_timeout(config=None) -> int # = resolution_timeouts[resolution](返回超时,随分辨率) response_timeout(config=None) -> int # = resolution_timeouts[resolution](返回超时,随分辨率)
``` ```
`default_config()` / `load_config()` 包含 `shopee_update` 执行配置段:历史/调试兼容测试商品 ID、更新内容模式 `update_mode`、每批最大更新条数、内部兼容 `dry_run`、同时更新蝦皮账号数 `max_parallel_accounts`。普通正式更新不再用测试商品 ID 或旧真实提交开关阻断当前筛选结果;封面是否参与本轮更新由③「更新内容」下拉决定;线上提交前的显式确认边界是③「开始更新」确认弹窗。`config.json` 不保存 AI Key;写入 `api_key` / `*_key` / `token` / `*_token` / `password` / `*_password` 等敏感字段时抛 `ConfigError`。普通产品默认 cmhub,AI Key 存 `data/config/cmhub.json`;`data/config/ai_models.json` 仅为 direct 内部兼容路径。T-538 后,配置中默认仍保存 `chrome_user_data_dir`、`images`、`cmshopee.db` 等相对值,运行时解析到 `data/` 下,保持免安装目录可移动。 `default_config()` / `load_config()` 包含 `shopee_update` 执行配置段:历史/调试兼容测试商品 ID、更新内容模式 `update_mode`、每批最大更新条数、内部兼容 `dry_run`、同时更新蝦皮账号数 `max_parallel_accounts`。普通正式更新不再用测试商品 ID 或旧真实提交开关阻断当前筛选结果;封面是否参与本轮更新由③「更新内容」下拉决定;线上提交前的显式确认边界是③「开始更新」确认弹窗。`product_suite.last_settings` 只保存⑥最近选择的平台/站点/语言/比例,供未绑定商品的新任务初始化;已有项目自己的 `suite_settings_json` 优先。`config.json` 不保存 AI Key;写入 `api_key` / `*_key` / `token` / `*_token` / `password` / `*_password` 等敏感字段时抛 `ConfigError`。普通产品默认 cmhub,AI Key 存 `data/config/cmhub.json`;`data/config/ai_models.json` 仅为 direct 内部兼容路径。T-538 后,配置中默认仍保存 `chrome_user_data_dir`、`images`、`cmshopee.db` 等相对值,运行时解析到 `data/` 下,保持免安装目录可移动。
敏感信息展示/日志辅助: 敏感信息展示/日志辅助:
+5 -4
View File
@@ -184,10 +184,10 @@
``` ```
┌ 套图任务1 │ 套图任务2 │ + ───────────────────────────────────┐ ┌ 套图任务1 │ 套图任务2 │ + ───────────────────────────────────┐
│ 账号[▼] 商品ID[____________] [拉取蝦皮主图] │ │ [历史生成][打开结果文件夹][添加图片] 账号[▼] 商品ID[____][拉取主图] │
├ 左侧配置(滚动)────────────┬ 右侧生成结果 ─────────────────────┤ ├ 左侧配置(滚动)────────────┬ 右侧生成结果 ─────────────────────┤
│ 商品原图:主图/参考1..5/添加 │ 共N张·成功M张 [历史生成][打开文件夹] │ │ 商品原图:主图/参考1..5/添加 │ 共N张·成功M张 │
│ 平台/国家/语言/比例 │ [结果卡][结果卡][失败卡·重试] │ │ 平台 站点 语言 比例(同行) │ [结果卡][结果卡][失败卡·重试] │
│ 每张上传图分别作为主图生成 │ │ │ 每张上传图分别作为主图生成 │ │
│ 商品卖点与要求 [AI帮写/取消] │ │ │ 商品卖点与要求 [AI帮写/取消] │ │
│ 白底图/场景图/卖点图/自定义类 │ 进度条 套图X/Y(秒)·失败N │ │ 白底图/场景图/卖点图/自定义类 │ 进度条 套图X/Y(秒)·失败N │
@@ -196,11 +196,12 @@
``` ```
- 每个顶部任务标签持有独立账号、商品ID、设置、原图、当前 job 集合和 worker;任务可并行生成。切换任务不停止后台操作;关闭运行中任务先确认并协作式取消,线程引用保留到真正结束,避免 `QThread: Destroyed while thread is still running`。 - 每个顶部任务标签持有独立账号、商品ID、设置、原图、当前 job 集合和 worker;任务可并行生成。切换任务不停止后台操作;关闭运行中任务先确认并协作式取消,线程引用保留到真正结束,避免 `QThread: Destroyed while thread is still running`。
- 二级套图任务标签使用独立紧凑样式,不继承主模块 Tab 的大尺寸点击区。上下文栏左侧集中「历史生成 / 打开结果文件夹 / 添加图片」,右侧集中账号、商品 ID 和拉取入口;常见 11~13 位商品 ID 不得裁切,长账号可通过 tooltip 查看完整名称。
- 项目仍以 `账号别名 + 商品ID` 唯一,复用 `image_studio_projects/assets/jobs`。`suite_settings_json` 保存平台、国家地区、语言、比例、逐图主图模式和分类数量;卖点文本继续使用 `draft_prompt`。 - 项目仍以 `账号别名 + 商品ID` 唯一,复用 `image_studio_projects/assets/jobs`。`suite_settings_json` 保存平台、国家地区、语言、比例、逐图主图模式和分类数量;卖点文本继续使用 `draft_prompt`。
- 商品原图最多16张。前6个槽位固定显示主图与参考1~5;支持文件选择、外部拖入、剪贴板粘贴和列表内排序。历史失效远程图不占有效名额;第1张是主参考图。 - 商品原图最多16张。前6个槽位固定显示主图与参考1~5;支持文件选择、外部拖入、剪贴板粘贴和列表内排序。历史失效远程图不占有效名额;第1张是主参考图。
- 「拉取蝦皮主图」复用只读 CDP,读取 URL 后由最多2个下载 worker 后台落盘;不改标题/封面、不拖拽、不点击更新。拉取、下载期间其余界面和其他任务仍可操作。 - 「拉取蝦皮主图」复用只读 CDP,读取 URL 后由最多2个下载 worker 后台落盘;不改标题/封面、不拖拽、不点击更新。拉取、下载期间其余界面和其他任务仍可操作。
- 套图只有一个图片类型,不再展示详情图、终选盘或模板 CRUD。默认分类为白底图1、场景图2、卖点图2;自定义分类名称非空、无空格、最多10字且不可重名。逐图主图开启后,白底图只生成一次,其余分类按每张有效原图展开。 - 套图只有一个图片类型,不再展示详情图、终选盘或模板 CRUD。默认分类为白底图1、场景图2、卖点图2;自定义分类名称非空、无空格、最多10字且不可重名。逐图主图开启后,白底图只生成一次,其余分类按每张有效原图展开。
- 平台、国家地区、语言和比例都写进每个 job 的完整提示词;比例还透传到 cmhub 生图请求,不是装饰字段。生成仍走 `image_studio_generation.run_jobs()` 的 submit → poll → download 管线。 - 平台、国家地区、语言和比例以四个带独立标签的同行下拉展示,选项只显示真实值;四项都写进每个 job 的完整提示词,比例还透传到 cmhub 生图请求,不是装饰字段。已有项目保存自己的完整设置;未绑定商品的新任务在重启后采用 `config.json` 的最近四项选择。生成仍走 `image_studio_generation.run_jobs()` 的 submit → poll → download 管线。
- 生成按钮按当前总数显示并在运行时切换为停止。结果区显示本轮或历史 job;成功图可预览、复制路径、打开目录、重新生成、移入项目废纸篓并撤销,失败卡显示脱敏中文摘要与重试入口。 - 生成按钮按当前总数显示并在运行时切换为停止。结果区显示本轮或历史 job;成功图可预览、复制路径、打开目录、重新生成、移入项目废纸篓并撤销,失败卡显示脱敏中文摘要与重试入口。
- AI帮写和生图按任务独立运行。AI帮写期间若用户改过卖点,返回后必须确认才覆盖;全部用户可见错误隐藏 URL/接口路径和敏感信息。 - AI帮写和生图按任务独立运行。AI帮写期间若用户改过卖点,返回后必须确认才覆盖;全部用户可见错误隐藏 URL/接口路径和敏感信息。
- ⑥只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。 - ⑥只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。
+11 -1
View File
@@ -3,7 +3,7 @@ id: T-627
title: 压缩商品套图顶部空间并重排上下文操作栏 title: 压缩商品套图顶部空间并重排上下文操作栏
phase: 7 phase: 7
deps: [T-622] deps: [T-622]
status: TODO status: DONE
created: 2026-07-14 created: 2026-07-14
--- ---
@@ -92,3 +92,13 @@ created: 2026-07-14
- 不修改拉取蝦皮主图的 CDP 流程、选择器或 Chrome 前台切换策略。 - 不修改拉取蝦皮主图的 CDP 流程、选择器或 Chrome 前台切换策略。
- 不修改①~⑤模块 UI,不修改主窗口全局标签样式。 - 不修改①~⑤模块 UI,不修改主窗口全局标签样式。
- 不新增平台、站点、语言或比例选项,不改变现有配置值的含义。 - 不新增平台、站点、语言或比例选项,不改变现有配置值的含义。
## 执行记录
- 2026-07-14 完成。
- ⑥二级任务标签改为 36px 独立紧凑样式,不再继承主模块 Tab 的大间距;顶部操作栏左侧集中「历史生成 / 打开结果文件夹 / 添加图片」,右侧集中账号、商品 ID 和拉取主图,账号下拉限制为 120~160px 并提供完整 tooltip,商品 ID 按 13 位数字字体度量控制在 120~140px。
- 生成设置改为四列同行等宽布局,保留「平台 / 站点 / 语言 / 比例」独立标签;下拉项只显示真实值,`itemData/currentData` 和生成提示词语义不变。
- `app/product_suite.py` 统一维护四类允许选项并归一化最近设置;`data/config.json` 新增 `product_suite.last_settings`。软件重启后的未绑定新任务读取最近四项选择,同会话新任务优先继承当前任务,绑定已有项目后再由 `suite_settings_json` 覆盖。配置更新后原位刷新共享 config,避免⑤后续保存丢失该段。
- 同步 `docs/04-architecture.md`、`docs/api.md` 与 `docs/routes.md`;新增纯逻辑、appconfig 和 GUI 测试,覆盖非法值回退、其他配置保留、四列顺序/等宽、工具栏位置、跨重启恢复及已有项目优先级。
- 1180×760 离屏截图检查通过,四个下拉框宽度均为 98px,顶部操作顺序为历史生成、打开结果文件夹、添加图片、账号、商品 ID、拉取主图,无重叠。
- 当前工作区全量测试只有任务开始前已存在的默认封面提示词改名导致 3 个旧 `papa1` 断言失败;在只包含 T-627 暂存内容的干净验证工作树中运行 `py -3.10 -m unittest discover -s tests`,473 项全部通过。`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check` 全部通过。
+69
View File
@@ -32,6 +32,15 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
self.assertEqual("title", appconfig.shopee_update_config(config)["update_mode"]) self.assertEqual("title", appconfig.shopee_update_config(config)["update_mode"])
self.assertNotIn("allow_cover_update", appconfig.shopee_update_config(config)) self.assertNotIn("allow_cover_update", appconfig.shopee_update_config(config))
self.assertEqual(1, appconfig.shopee_update_config(config)["max_parallel_accounts"]) self.assertEqual(1, appconfig.shopee_update_config(config)["max_parallel_accounts"])
self.assertEqual(
{
"platform": "Shopee",
"country": "中国台湾",
"language": "繁体中文",
"ratio": "1:1",
},
appconfig.product_suite_last_settings(config),
)
updated = appconfig.update_config( updated = appconfig.update_config(
{"ai": {"resolution": "2k"}}, {"ai": {"resolution": "2k"}},
@@ -66,6 +75,66 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
def test_product_suite_last_settings_are_normalized_and_preserve_other_config(self):
with self.make_temp_dir() as temp_dir:
config_path = os.path.join(temp_dir, "config.json")
with open(config_path, "w", encoding="utf-8") as fh:
json.dump(
{
"chrome_path": "custom-chrome.exe",
"custom_section": {"keep": True},
"product_suite": {
"last_settings": {
"platform": "未知平台",
"country": "新加坡",
"language": "英文",
"ratio": "2:3",
"unexpected": "ignore",
}
},
},
fh,
ensure_ascii=False,
)
loaded = appconfig.load_config(config_path)
self.assertEqual(
{
"platform": "Shopee",
"country": "新加坡",
"language": "英文",
"ratio": "1:1",
},
appconfig.product_suite_last_settings(loaded),
)
updated = appconfig.update_config(
{
"product_suite": {
"last_settings": {
"platform": "Amazon",
"country": "中国台湾",
"language": "繁体中文",
"ratio": "16:9",
}
}
},
path=config_path,
)
self.assertEqual("custom-chrome.exe", updated["chrome_path"])
self.assertEqual({"keep": True}, updated["custom_section"])
self.assertEqual("16:9", appconfig.product_suite_last_settings(updated)["ratio"])
with open(config_path, "r", encoding="utf-8") as fh:
persisted = json.load(fh)
self.assertEqual(
{"platform", "country", "language", "ratio"},
set(persisted["product_suite"]["last_settings"]),
)
self.assert_removed(temp_dir)
def test_shopee_update_legacy_parallel_config_is_migrated(self): def test_shopee_update_legacy_parallel_config_is_migrated(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
config_path = os.path.join(temp_dir, "config.json") config_path = os.path.join(temp_dir, "config.json")
+32
View File
@@ -37,6 +37,38 @@ class ProductSuiteTests(unittest.TestCase):
self.assertEqual(["白底图", "场景图", "卖点图", "尺寸图"], product_suite.category_order(settings)) self.assertEqual(["白底图", "场景图", "卖点图", "尺寸图"], product_suite.category_order(settings))
self.assertEqual(5, product_suite.suite_total_count(settings, 1)) self.assertEqual(5, product_suite.suite_total_count(settings, 1))
def test_recent_settings_only_keep_allowed_dropdown_values(self):
self.assertEqual(
{
"platform": "Shopee",
"country": "中国台湾",
"language": "繁体中文",
"ratio": "1:1",
},
product_suite.last_suite_settings(
{
"platform": "未知平台",
"country": "未知站点",
"language": "未知语言",
"ratio": "2:3",
"per_image_primary": True,
"categories": {"白底图": 9},
}
),
)
selected = product_suite.last_suite_settings(
{
"platform": "Amazon",
"country": "新加坡",
"language": "英文",
"ratio": "4:3",
}
)
self.assertEqual("Amazon", selected["platform"])
self.assertEqual("新加坡", selected["country"])
self.assertEqual("英文", selected["language"])
self.assertEqual("4:3", selected["ratio"])
def test_job_specs_include_selected_context_and_source_assignment(self): def test_job_specs_include_selected_context_and_source_assignment(self):
settings = product_suite.default_suite_settings() settings = product_suite.default_suite_settings()
settings.update( settings.update(
+110 -1
View File
@@ -7,7 +7,7 @@ sys.path.insert(0, os.path.dirname(__file__))
from _helpers import TempDirMixin from _helpers import TempDirMixin
from app import accounts, image_studio, image_studio_images from app import accounts, appconfig, image_studio, image_studio_images
from app import gui from app import gui
if gui.QT_IMPORT_ERROR is not None: if gui.QT_IMPORT_ERROR is not None:
@@ -52,6 +52,9 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
accounts.create_account("主店", "alias-a", debug_port=9222, config=config) accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
tab = ProductSuiteTab(config=config, db_path=config["db_path"]) tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close) self.addCleanup(tab.close)
tab.resize(1180, 760)
tab.show()
self.app.processEvents()
self.assertEqual("productSuiteTab", tab.objectName()) self.assertEqual("productSuiteTab", tab.objectName())
self.assertEqual(1, tab.task_tabs.count()) self.assertEqual(1, tab.task_tabs.count())
@@ -61,6 +64,54 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
self.assertEqual("中国台湾", tab.country_combo.currentData()) self.assertEqual("中国台湾", tab.country_combo.currentData())
self.assertEqual("繁体中文", tab.language_combo.currentData()) self.assertEqual("繁体中文", tab.language_combo.currentData())
self.assertEqual("1:1", tab.ratio_combo.currentData()) self.assertEqual("1:1", tab.ratio_combo.currentData())
self.assertEqual("Shopee", tab.platform_combo.currentText())
self.assertEqual("中国台湾", tab.country_combo.currentText())
self.assertEqual("繁体中文", tab.language_combo.currentText())
self.assertEqual("1:1", tab.ratio_combo.currentText())
self.assertEqual("平台", tab.platform_label.text())
self.assertEqual("站点", tab.country_label.text())
self.assertEqual("语言", tab.language_label.text())
self.assertEqual("比例", tab.ratio_label.text())
for column, combo in enumerate(
(
tab.platform_combo,
tab.country_combo,
tab.language_combo,
tab.ratio_combo,
)
):
self.assertIs(combo, tab.settings_grid.itemAtPosition(1, column).widget())
self.assertEqual(
1,
len(
{
tab.platform_combo.width(),
tab.country_combo.width(),
tab.language_combo.width(),
tab.ratio_combo.width(),
}
),
)
self.assertLessEqual(tab.task_tabs.maximumHeight(), 36)
self.assertEqual(120, tab.account_combo.minimumWidth())
self.assertEqual(160, tab.account_combo.maximumWidth())
self.assertGreaterEqual(tab.item_id_edit.minimumWidth(), 120)
self.assertLessEqual(tab.item_id_edit.maximumWidth(), 140)
self.assertLess(
tab.context_bar_layout.indexOf(tab.history_button),
tab.context_bar_layout.indexOf(tab.account_combo),
)
self.assertLess(
tab.context_bar_layout.indexOf(tab.open_folder_button),
tab.context_bar_layout.indexOf(tab.account_combo),
)
self.assertLess(
tab.context_bar_layout.indexOf(tab.add_images_button),
tab.context_bar_layout.indexOf(tab.account_combo),
)
self.assertEqual(-1, tab.results_toolbar_layout.indexOf(tab.history_button))
self.assertEqual(-1, tab.results_toolbar_layout.indexOf(tab.open_folder_button))
self.assertEqual("打开结果文件夹", tab.open_folder_button.text())
self.assertEqual("合计 5 张", tab.category_total_label.text()) self.assertEqual("合计 5 张", tab.category_total_label.text())
self.assertEqual("生成套图(5)", tab.generate_button.text()) self.assertEqual("生成套图(5)", tab.generate_button.text())
@@ -84,6 +135,64 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
def test_recent_dropdown_settings_restore_after_restart_and_project_wins(self):
with self.make_temp_dir() as temp_dir:
config_path = os.path.join(temp_dir, "config.json")
config = appconfig.save_config(self._config(temp_dir), path=config_path)
account = accounts.create_account(
"主店",
"alias-a",
debug_port=9222,
config=config,
)
project = image_studio.create_or_get_project(
account,
item_id="51100639510",
path=config["db_path"],
)
project_settings = image_studio.project_suite_settings(project)
project_settings["ratio"] = "4:3"
image_studio.update_project_suite_settings(
project.id,
project_settings,
path=config["db_path"],
)
first = ProductSuiteTab(
config=config,
config_path=config_path,
db_path=config["db_path"],
)
first.ratio_combo.setCurrentIndex(first.ratio_combo.findData("16:9"))
self.assertEqual(
"16:9",
appconfig.product_suite_last_settings(
appconfig.load_config(config_path)
)["ratio"],
)
first.close()
first.deleteLater()
self.app.processEvents()
reloaded = appconfig.load_config(config_path)
second = ProductSuiteTab(
config=reloaded,
config_path=config_path,
db_path=reloaded["db_path"],
)
self.addCleanup(second.close)
self.assertEqual("16:9", second.ratio_combo.currentData())
state = second._displayed_state
state.account_alias = "alias-a"
state.item_id = "51100639510"
second._bind_project(state, load_existing=True)
self.assertEqual("4:3", second.ratio_combo.currentData())
self.assertEqual("4:3", state.settings["ratio"])
self.assert_removed(temp_dir)
def test_task_tabs_keep_independent_prompt_and_settings(self): def test_task_tabs_keep_independent_prompt_and_settings(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir) config = self._config(temp_dir)