From f767ec266045f275e4882266f5a66d8a884a0479 Mon Sep 17 00:00:00 2001 From: ila Date: Tue, 16 Jun 2026 17:01:58 +0800 Subject: [PATCH] feat: persist template/batch-mode, per-aspect resets, de-dup template actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §17.1 Preferences persistence: - Wire the previously-unused config_service; remember and restore the last-selected template and last batch mode. main_window loads config, distributes initial values, then persists on change (save-on-change). - template_panel: template_changed signal, select_template, current_template_state. queue_panel: set_batch_mode + batch_mode_changed. §17.2 Independent resets: - Replace the combined reset and 归零 with per-section 位置/尺寸/旋转 reset buttons (quiet ghost styling) that restore only their own aspect to the selected template. Size reset changes width/height only, leaving X/Y. Button de-dup: - Remove the export panel's 应用为模板 / 重置为模板 — literal duplicates of the template panel's 另存为 / 重置为模板. Template actions now live only in the template area; the export panel handles output only. Docs: PRD (6.6/6.9/9/10), architecture (4.12), UI design (7.x/10), development-rules (6, single-home actions + consistent naming), tasks.md (17). Co-Authored-By: Claude Opus 4.8 --- docs/02-prd.md | 25 ++++++ docs/04-development-rules.md | 4 + docs/05-project-architecture.md | 4 + docs/07-ui-design.md | 32 ++++++-- src/app/main_window.py | 84 ++++++++++++++++++-- src/app/widgets/export_panel.py | 35 +-------- src/app/widgets/queue_panel.py | 19 ++++- src/app/widgets/template_panel.py | 17 ++++ src/app/widgets/transform_panel.py | 120 ++++++++++++++--------------- src/services/config_service.py | 2 + tasks.md | 73 +++++++++++++++++- 11 files changed, 300 insertions(+), 115 deletions(-) diff --git a/docs/02-prd.md b/docs/02-prd.md index 2997e00..6daa9e7 100644 --- a/docs/02-prd.md +++ b/docs/02-prd.md @@ -164,6 +164,8 @@ - 模板的 `width_ratio` / `height_ratio` 定义衣服上的**目标框**,而非强制拉伸尺寸。已知印花原始尺寸时,印花按自身宽高比缩放后 contain 进目标框并居中,不被拉伸变形;未知印花尺寸时退化为铺满目标框。 - 内置模板尺寸以贴近真实印花占比为准(例如正方形模板约为衣服宽度的 1/3),避免印花占据过大面积。 - 选中的模板是**粘性**的:在预览区切换衣服或切换印花时,印花不应丢失,应按当前选中模板基于新的衣服/印花尺寸重新计算落位,而非回退到画布的写死默认值。已手动微调的项不受影响,仍使用其自身参数。 +- **记住上次选择的模板**:程序退出前持久化当前选中的模板,下次启动自动恢复选中该模板。若该模板为自定义模板且已被删除,则回退到第一个内置模板。 +- 提供 `位置` / `尺寸` / `旋转` **三项独立的重置**,分别只把对应参数还原为当前选中模板的值(位置→模板居中位置;尺寸→模板目标框内适配的大小;旋转→模板角度,通常为 0),三者互不影响;不再使用单一的合并重置。未选模板或未加载衣服/印花时,重置不可用。 ### 6.7 自定义模板 @@ -203,6 +205,7 @@ 要求: - 用户可以选择批量模式。 +- **记住上次选择的批量模式**:程序退出前持久化当前批量模式,下次启动自动恢复;持久化值非法时回退到默认模式(全组合)。 - 批量导出使用当前模板或当前编辑参数。 - 显示合成进度。 - 支持取消批量任务。 @@ -308,6 +311,26 @@ AutoPrint/ } ``` +应用配置建议(`app_config.json`,记录用户偏好,缺失或损坏时使用安全默认值): + +```json +{ + "output_format": "PNG", + "output_quality": 95, + "output_dir": "", + "last_garment_dir": "", + "last_print_dir": "", + "last_template": "正方形模板", + "last_batch_mode": "full_combo" +} +``` + +说明: + +- `last_template`:上次选中的模板名称,用于启动恢复。 +- `last_batch_mode`:上次选择的批量模式(取 `BatchMode` 枚举值,如 `full_combo` / `many_garments` / `many_prints` / `one_to_one`)。 +- 偏好的读取、分发与「改一次存一次」由主窗口集中处理,UI 控件不直接读写配置文件。 + ## 10. 验收标准 - 可以加载衣服图片文件夹和印花图片文件夹。 @@ -318,6 +341,8 @@ AutoPrint/ - 可以旋转印花并同步角度。 - 可以选择内置模板并应用到预览。 - 可以保存和复用自定义模板。 +- 程序重启后自动恢复上一次选择的模板和批量模式。 +- 可以分别把印花的位置、尺寸、旋转独立重置为当前选中模板的值。 - 可以导出当前单张合成图。 - 可以按多衣服 × 单印花模式批量导出。 - 可以按单衣服 × 多印花模式批量导出。 diff --git a/docs/04-development-rules.md b/docs/04-development-rules.md index ab42d85..baf6f87 100644 --- a/docs/04-development-rules.md +++ b/docs/04-development-rules.md @@ -88,6 +88,9 @@ - 用户通过参数输入框修改坐标、尺寸、角度后,预览必须同步更新。 - 批量处理时界面不能长时间无响应。 - 失败、缺文件、格式不支持、导出失败等情况必须给出用户可理解的提示。 +- 同一个动作只应有一个入口;确需在多处触发时,必须使用同一个名称,且指向同一个实现。 +- 「某个动作放在哪个面板」是一次性决定,不在每个面板各自重复设置。 +- 逐面板实现 UI 后,应做一次跨面板整体走查,确认没有重复入口或同义不同名的按钮。 禁止事项: @@ -96,6 +99,7 @@ - 禁止让用户只能通过拖拽完成精确定位,必须保留参数输入能力。 - 禁止批量处理期间阻塞主界面且没有进度反馈。 - 禁止使用含糊错误提示,例如只显示“失败”。 +- 禁止同一个动作在不同面板出现重复入口,或用不同名称表示同一个动作(例如「另存为」与「应用为模板」)。 ## 7. 配置与模板规则 diff --git a/docs/05-project-architecture.md b/docs/05-project-architecture.md index 901a591..96cc744 100644 --- a/docs/05-project-architecture.md +++ b/docs/05-project-architecture.md @@ -240,11 +240,15 @@ BatchResult - 读取和保存应用配置。 - 管理默认配置。 - 在配置损坏或缺失时提供安全默认值。 +- 持久化用户偏好:输出设置、上次选择的模板(`last_template`)、上次选择的批量模式(`last_batch_mode`)等。 + +由主窗口集中使用:启动时加载一次并把初值分发给各面板,面板选择变化时「改一次存一次」回写。各 UI 控件不直接读写配置文件,避免分散解析。 禁止: - 禁止在多个模块中重复解析同一个配置文件。 - 禁止启动时无提示清空用户配置。 +- 禁止 UI 控件绕过本服务直接读写配置文件。 ### 4.13 `services/template_service.py` diff --git a/docs/07-ui-design.md b/docs/07-ui-design.md index c2b9d89..6fb18b6 100644 --- a/docs/07-ui-design.md +++ b/docs/07-ui-design.md @@ -189,6 +189,14 @@ Shift 锁比例 右侧参数栏用于模板、位置、尺寸、旋转和输出设置。 +`位置`、`尺寸`、`旋转` 三个分区各自在**分区标题栏右侧**放一个小型 `重置` 按钮(`↺` 图标 + 文字),只把本分区参数还原为当前选中模板的对应值,三者互不影响: + +- 位置重置 → 还原为模板的居中位置(X / Y)。 +- 尺寸重置 → 还原为模板目标框内适配的大小(宽 / 高)。 +- 旋转重置 → 还原为模板角度(通常为 0)。 + +未选模板或未加载衣服/印花时三个重置按钮置灰。视觉层级见第 10 节「重置类按钮」。 + ### 7.1 模板区域 控件: @@ -196,6 +204,7 @@ Shift 锁比例 - 模板下拉框。 - `保存` 按钮。 - `另存为` 按钮。 +- `重置为模板` 按钮(把印花参数一次性整体还原为所选模板)。 示例模板: @@ -216,6 +225,7 @@ Shift 锁比例 - `X 坐标` 数字输入框。 - `Y 坐标` 数字输入框。 +- 分区标题右侧的 `重置` 按钮(还原 X / Y 为模板值)。 要求: @@ -230,6 +240,7 @@ Shift 锁比例 - `宽度` 数字输入框。 - `高度` 数字输入框。 - `锁定宽高比例` 复选框。 +- 分区标题右侧的 `重置` 按钮(仅还原宽 / 高为模板值,位置 X / Y 不变)。 要求: @@ -243,7 +254,7 @@ Shift 锁比例 控件: - `角度` 数字输入框。 -- `归零` 按钮(紧邻角度输入框,一键将角度还原为 0,即拉直印花)。 +- 分区标题右侧的 `重置` 按钮(还原角度为模板值,即拉直印花)。 - `左旋 90°` 按钮。 - `右旋 90°` 按钮。 @@ -253,7 +264,7 @@ Shift 锁比例 - 支持负角度。 - 顶部旋转控制点拖动后同步角度输入框。 - 按钮旋转后同步预览和角度输入框。 -- 参数栏底部提供 `重置位置 / 尺寸 / 角度` 按钮,将三者一并还原为当前所选模板(与导出区的 `重置为模板` 等效,就近放在参数编辑处)。 +- 旋转分区标题的 `重置` 已覆盖「拉直」需求,不再单独提供 `归零` 按钮;也不再提供合并的 `重置位置 / 尺寸 / 角度` 按钮,统一由三个分区各自的独立重置取代。 ### 7.5 输出设置区域 @@ -291,14 +302,12 @@ Shift 锁比例 主要按钮: - `导出当前单张` -- `应用为模板` -- `重置为模板` 要求: - `导出当前单张` 使用主按钮样式。 -- 其他按钮使用次级按钮样式。 - 当前缺少衣服图或印花图时,导出按钮应禁用。 +- 导出面板只负责输出与导出;模板相关操作(保存 / 另存为 / 重置为模板)统一放在模板区域(7.1),不在导出面板重复设置,避免同一动作出现两次。 ## 8. 底部合成队列 @@ -447,8 +456,17 @@ Microsoft YaHei 按钮层级(一套主色,按角色克制使用): - **主按钮**:主色填充、白字。用于每个区域「推动流程前进」的唯一动作:左侧 `打开文件夹`、右侧 `导出当前单张`、底部 `开始批量导出`。三者分处不同区域,各自是本区域主操作,不互相竞争。 -- **次按钮**:白底、主色描边与文字。用于有意义但非主线的动作,如 `重置位置 / 尺寸 / 角度`、`应用为模板`。 -- **辅助按钮**:浅灰底、细边框。用于就地微调类动作,如 `浏览…`、排序、`左旋 / 右旋 / 归零`。 +- **次按钮**:白底、主色描边与文字。用于有意义但非主线的动作,如 `应用为模板`、导出区的 `重置为模板`。 +- **辅助按钮**:浅灰底、细边框。用于就地微调类动作,如 `浏览…`、排序、`左旋 / 右旋`。 +- **重置类按钮**:最安静的一档(幽灵样式),用于 `位置 / 尺寸 / 旋转` 三个分区的独立重置。它们会重复出现且属于「纠正/撤销」类动作,不应占用常驻强调色;强调色只在悬停时短暂出现以表达意图。重置可逆,因此**不使用红色/警告色**。 + +```text +重置类按钮(分区标题内的 ↺ 重置): +静止:背景 透明 / #f7f7f7 图标+文字 #8a8a8a 无边框 +悬停:背景 #eaf3fc 图标+文字 #0078d4 +按下:背景 #d6e8f9 +禁用:文字 #c0c0c0 不响应悬停(未选模板或未加载图片时) +``` 避免所有按钮使用同一种灰色样式而丧失层级;同一动作在不同位置应使用一致的文案与层级。 diff --git a/src/app/main_window.py b/src/app/main_window.py index 79cf9a1..5dfb4c0 100644 --- a/src/app/main_window.py +++ b/src/app/main_window.py @@ -14,6 +14,8 @@ from PySide6.QtWidgets import ( ) from version import APP_NAME, APP_VERSION +from core.models import BatchMode, TransformState +from services.config_service import load_config, save_config from app.widgets.export_panel import ExportPanel from app.widgets.image_canvas import ImageCanvas from app.widgets.image_list_panel import ImageListPanel @@ -66,6 +68,7 @@ class MainWindow(QMainWindow): self.setCentralWidget(root) self._apply_stylesheet() self._connect_signals() + self._restore_preferences() def _create_header(self): """Top header: app name, version label, settings button.""" @@ -270,8 +273,10 @@ class MainWindow(QMainWindow): self.image_canvas.transform_changed.connect(self._on_transform_changed) self.transform_panel.transform_changed.connect(self._on_transform_changed) - # Reset position/size/angle back to the selected template - self.transform_panel.reset_to_template.connect(self.template_panel._reset_to_template) + # Per-section reset: restore only that aspect to the selected template + self.transform_panel.reset_position.connect(lambda: self._reset_aspect("pos")) + self.transform_panel.reset_size.connect(lambda: self._reset_aspect("size")) + self.transform_panel.reset_rotation.connect(lambda: self._reset_aspect("rot")) # ── Template panel ───────────────────────────────────────────────── # Template selection updates canvas, param panel, and queue template @@ -280,10 +285,6 @@ class MainWindow(QMainWindow): self.template_panel.template_applied.connect(self.queue_panel.set_transform) self.template_panel.template_applied.connect(self.export_panel.set_transform) - # ── Export panel secondary buttons → template panel ──────────────── - self.export_panel.apply_as_template.connect(self.template_panel._save_as_template) - self.export_panel.reset_to_template.connect(self.template_panel._reset_to_template) - def _load_print_preview(self, path): """Load a print into the canvas and sync its native size to the template panel.""" self.image_canvas.load_print(path) @@ -300,6 +301,7 @@ class MainWindow(QMainWindow): self._active_queue_item = None # leave queue mode if not asset: self.export_panel.set_garment(None) + self._update_reset_availability() return path = str(asset.path) self.image_canvas.load_garment(path) @@ -316,6 +318,7 @@ class MainWindow(QMainWindow): if self._current_print_path: self._load_print_preview(self._current_print_path) self.template_panel.apply_current() + self._update_reset_availability() def _on_print_preview(self, asset): """Load a print from the asset list into the canvas for direct preview.""" @@ -323,12 +326,14 @@ class MainWindow(QMainWindow): if not asset: self._current_print_path = None self.export_panel.set_print(None) + self._update_reset_availability() return self._current_print_path = str(asset.path) self._load_print_preview(self._current_print_path) # Apply the selected template so the print lands per the chosen layout, # not the canvas's hard-coded centred default. self.template_panel.apply_current() + self._update_reset_availability() def _on_queue_item_activated(self, item): """Load a queue item into the canvas and arm fine-tune tracking.""" @@ -356,6 +361,7 @@ class MainWindow(QMainWindow): pass finally: self._loading_queue_item = False + self._update_reset_availability() def _on_transform_changed(self, state): """Route transform changes to panels and mark the active queue item fine-tuned.""" @@ -367,6 +373,72 @@ class MainWindow(QMainWindow): return self.queue_panel.mark_item_fine_tuned(self._active_queue_item, state) + # ------------------------------------------------------------------ + # Per-aspect reset (position / size / rotation → selected template) + # ------------------------------------------------------------------ + + def _update_reset_availability(self): + """Enable the section resets only when a print is on the canvas.""" + has_print = self.image_canvas.get_transform() is not None + self.transform_panel.set_resets_enabled(has_print) + + def _reset_aspect(self, aspect): + """Restore only one aspect of the current transform to the selected template.""" + tpl = self.template_panel.current_template_state() + cur = self.image_canvas.get_transform() + if tpl is None or cur is None: + return + new = TransformState( + x=cur.x, y=cur.y, width=cur.width, height=cur.height, + rotation=cur.rotation, keep_aspect_ratio=cur.keep_aspect_ratio, + ) + if aspect == "pos": + # Centre the current-sized print on the template's centre + new.x = (tpl.x + tpl.width / 2.0) - cur.width / 2.0 + new.y = (tpl.y + tpl.height / 2.0) - cur.height / 2.0 + elif aspect == "size": + # Only the size; leave position (x/y) untouched so each reset is independent + new.width, new.height = tpl.width, tpl.height + elif aspect == "rot": + new.rotation = tpl.rotation + self._apply_preview_transform(new) + + def _apply_preview_transform(self, state): + """Push a transform to the canvas + panels as if it were a user edit.""" + self.image_canvas.set_transform(state) + self.transform_panel.set_transform(state) + self._on_transform_changed(state) + + # ------------------------------------------------------------------ + # Preference persistence (last template / last batch mode) + # ------------------------------------------------------------------ + + def _restore_preferences(self): + """Load saved preferences, apply them, then start persisting changes.""" + self._config = load_config() + + name = self._config.get("last_template", "") + if name: + self.template_panel.select_template(name) + + raw_mode = self._config.get("last_batch_mode", "") + try: + self.queue_panel.set_batch_mode(BatchMode(raw_mode)) + except ValueError: + logger.warning("Unknown last_batch_mode %r, keeping default", raw_mode) + + # Persist future changes (connected after restore to avoid echo writes) + self.template_panel.template_changed.connect(self._on_template_persist) + self.queue_panel.batch_mode_changed.connect(self._on_batch_mode_persist) + + def _on_template_persist(self, name): + self._config["last_template"] = name + save_config(self._config) + + def _on_batch_mode_persist(self, mode): + self._config["last_batch_mode"] = getattr(mode, "value", str(mode)) + save_config(self._config) + # ------------------------------------------------------------------ # Style (ref: docs/07-ui-design.md §10) # ------------------------------------------------------------------ diff --git a/src/app/widgets/export_panel.py b/src/app/widgets/export_panel.py index fe4ac2e..7207149 100644 --- a/src/app/widgets/export_panel.py +++ b/src/app/widgets/export_panel.py @@ -2,7 +2,7 @@ import logging from pathlib import Path from typing import Optional -from PySide6.QtCore import Qt, Signal +from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QComboBox, QFileDialog, @@ -24,10 +24,7 @@ logger = logging.getLogger(__name__) class ExportPanel(QWidget): - """Right-panel section: output settings and export / template actions.""" - - apply_as_template = Signal() # "应用为模板" clicked - reset_to_template = Signal() # "重置为模板" clicked + """Right-panel section: output settings and single-image export.""" def __init__(self, parent=None): super().__init__(parent) @@ -58,7 +55,6 @@ class ExportPanel(QWidget): body_col.addLayout(self._make_format_form()) body_col.addWidget(self._make_hint_label()) body_col.addWidget(self._make_export_btn()) - body_col.addLayout(self._make_secondary_btns()) col.addWidget(body) @@ -116,24 +112,6 @@ class ExportPanel(QWidget): self._export_btn.clicked.connect(self._do_export) return self._export_btn - def _make_secondary_btns(self) -> QHBoxLayout: - row = QHBoxLayout() - row.setSpacing(6) - - apply_btn = QPushButton("应用为模板") - apply_btn.setObjectName("exportSecBtn") - apply_btn.setToolTip("将当前印花参数另存为新模板") - apply_btn.clicked.connect(lambda: self.apply_as_template.emit()) - - reset_btn = QPushButton("重置为模板") - reset_btn.setObjectName("exportSecBtn") - reset_btn.setToolTip("将印花参数还原为所选模板的默认值") - reset_btn.clicked.connect(lambda: self.reset_to_template.emit()) - - row.addWidget(apply_btn) - row.addWidget(reset_btn) - return row - # ── public API ─────────────────────────────────────────────────────────── def set_garment(self, path: Optional[str]): @@ -284,13 +262,4 @@ class ExportPanel(QWidget): background-color: #c8c8c8; color: #888888; } - #exportSecBtn { - font-size: 12px; - padding: 4px 8px; - border: 1px solid #d6d6d6; - border-radius: 3px; - background: #f0f0f0; - } - #exportSecBtn:hover { background: #e0e0e0; } - #exportSecBtn:pressed { background: #d0d0d0; } """) diff --git a/src/app/widgets/queue_panel.py b/src/app/widgets/queue_panel.py index 8ebb74a..02ec1cf 100644 --- a/src/app/widgets/queue_panel.py +++ b/src/app/widgets/queue_panel.py @@ -171,7 +171,8 @@ class _ProgressDelegate(QStyledItemDelegate): class QueuePanel(QWidget): """Bottom queue: batch mode selector, stats, action buttons, item table.""" - item_activated = Signal(object) # QueueItem when a row is selected + item_activated = Signal(object) # QueueItem when a row is selected + batch_mode_changed = Signal(object) # BatchMode when the mode selector changes def __init__(self, parent=None): super().__init__(parent) @@ -208,7 +209,7 @@ class QueuePanel(QWidget): self._mode_combo.addItem("多衣服 × 单印花", BatchMode.MANY_GARMENTS) self._mode_combo.addItem("单衣服 × 多印花", BatchMode.MANY_PRINTS) self._mode_combo.addItem("一一匹配", BatchMode.ONE_TO_ONE) - self._mode_combo.currentIndexChanged.connect(self._rebuild_queue) + self._mode_combo.currentIndexChanged.connect(self._on_mode_changed) self._stats_lbl = QLabel("共 0 项") self._stats_lbl.setObjectName("queueStatsLabel") @@ -279,6 +280,16 @@ class QueuePanel(QWidget): def set_export_options(self, options: ExportOptions): self._export_options = options + def set_batch_mode(self, mode: BatchMode): + """Restore the batch mode selector without re-triggering persistence.""" + idx = self._mode_combo.findData(mode) + if idx < 0: + return + self._mode_combo.blockSignals(True) + self._mode_combo.setCurrentIndex(idx) + self._mode_combo.blockSignals(False) + self._rebuild_queue() + def mark_item_fine_tuned(self, item: "QueueItem", state: TransformState): """Save a per-item transform and mark it as fine-tuned in the table.""" try: @@ -291,6 +302,10 @@ class QueuePanel(QWidget): # ── internal slots ─────────────────────────────────────────────────────── + def _on_mode_changed(self): + self._rebuild_queue() + self.batch_mode_changed.emit(self._mode_combo.currentData()) + def _rebuild_queue(self): mode = self._mode_combo.currentData() sel_g = [g for g in self._garments if g.selected] diff --git a/src/app/widgets/template_panel.py b/src/app/widgets/template_panel.py index 7487640..732bde4 100644 --- a/src/app/widgets/template_panel.py +++ b/src/app/widgets/template_panel.py @@ -26,6 +26,7 @@ class TemplatePanel(QWidget): """Right-panel section: template picker, save, save-as, reset.""" template_applied = Signal(object) # emits TransformState + template_changed = Signal(str) # emits the selected template name (for persistence) def __init__(self, parent=None): super().__init__(parent) @@ -151,6 +152,18 @@ class TemplatePanel(QWidget): self._current_state = state self.template_applied.emit(state) + def select_template(self, name: str): + """Select a template by name (used to restore the last-used template).""" + if name: + self._load_templates(select_name=name) + + def current_template_state(self) -> Optional[TransformState]: + """Return the current template's pixel TransformState, or None if not computable.""" + t = self._current_template() + if t and self._garment_w > 0 and self._garment_h > 0: + return self._state_for_template(t) + return None + def set_transform(self, state: TransformState): """Store current print transform (used when saving a template).""" self._current_state = state @@ -162,6 +175,8 @@ class TemplatePanel(QWidget): return self._update_button_states() t = self._combo.itemData(index) + if t: + self.template_changed.emit(t.name) # persist selection if t and self._garment_w > 0 and self._garment_h > 0: state = self._state_for_template(t) logger.debug("Template applied: %s", t.name) @@ -196,6 +211,7 @@ class TemplatePanel(QWidget): add_template(self._state_to_template(t.name)) logger.info("Template saved: %s", t.name) self._load_templates(select_name=t.name) + self.template_changed.emit(t.name) def _save_as_template(self): """Create a new custom template from the current transform.""" @@ -215,6 +231,7 @@ class TemplatePanel(QWidget): add_template(self._state_to_template(name)) logger.info("Template saved-as: %s", name) self._load_templates(select_name=name) + self.template_changed.emit(name) # ── helpers ────────────────────────────────────────────────────────────── diff --git a/src/app/widgets/transform_panel.py b/src/app/widgets/transform_panel.py index c0a4ef1..7474850 100644 --- a/src/app/widgets/transform_panel.py +++ b/src/app/widgets/transform_panel.py @@ -23,12 +23,16 @@ class TransformPanel(QWidget): """Right-panel section: position, size, aspect-ratio lock, rotation.""" transform_changed = Signal(object) # emits TransformState - reset_to_template = Signal() # "重置位置/尺寸/角度" clicked + # Per-section reset to the selected template's value for that aspect only + reset_position = Signal() + reset_size = Signal() + reset_rotation = Signal() def __init__(self, parent=None): super().__init__(parent) self._updating = False # guard against signal loops self._aspect_ratio = 1.0 + self._reset_btns = {} # tag -> reset QPushButton (for enable/disable) self._setup_ui() self._apply_stylesheet() @@ -39,20 +43,38 @@ class TransformPanel(QWidget): col.setContentsMargins(0, 0, 0, 0) col.setSpacing(0) - col.addWidget(self._make_section("位置", self._make_pos_form())) - col.addWidget(self._make_section("尺寸", self._make_size_form())) - col.addWidget(self._make_section("旋转", self._make_rot_form())) - col.addWidget(self._make_footer()) + col.addWidget(self._make_section("位置", self._make_pos_form(), "pos")) + col.addWidget(self._make_section("尺寸", self._make_size_form(), "size")) + col.addWidget(self._make_section("旋转", self._make_rot_form(), "rot")) - def _make_section(self, title: str, body: QWidget) -> QWidget: + def _make_section(self, title: str, body: QWidget, reset_tag: str) -> QWidget: section = QWidget() section.setObjectName("transformSection") col = QVBoxLayout(section) col.setContentsMargins(0, 0, 0, 0) col.setSpacing(0) - header = QLabel(title) + # Header row: title (left) + ghost reset button (right) + header = QWidget() header.setObjectName("transformSectionHeader") + hrow = QHBoxLayout(header) + hrow.setContentsMargins(10, 2, 4, 2) + hrow.setSpacing(0) + + title_lbl = QLabel(title) + title_lbl.setObjectName("transformSectionTitle") + + reset_btn = QPushButton("↺ 重置") + reset_btn.setObjectName("sectionResetBtn") + reset_btn.setToolTip("将本组参数还原为当前选中模板的值") + reset_btn.setEnabled(False) # enabled once a template + print are available + reset_btn.clicked.connect(lambda: self._emit_reset(reset_tag)) + self._reset_btns[reset_tag] = reset_btn + + hrow.addWidget(title_lbl) + hrow.addStretch() + hrow.addWidget(reset_btn) + col.addWidget(header) col.addWidget(body) return section @@ -116,20 +138,7 @@ class TransformPanel(QWidget): self._rot_spin.setSingleStep(1.0) self._rot_spin.valueChanged.connect(self._on_rot_changed) - # Angle field: spinbox + inline "归零" (reset angle to 0) - rot_field = QWidget() - rot_row = QHBoxLayout(rot_field) - rot_row.setContentsMargins(0, 0, 0, 0) - rot_row.setSpacing(4) - self._rot_zero_btn = QPushButton("归零") - self._rot_zero_btn.setObjectName("rotZeroBtn") - self._rot_zero_btn.setFixedWidth(46) - self._rot_zero_btn.setToolTip("将角度归零(拉直印花)") - self._rot_zero_btn.clicked.connect(self._reset_rotation) - rot_row.addWidget(self._rot_spin, 1) - rot_row.addWidget(self._rot_zero_btn) - - form.addRow("角度", rot_field) + form.addRow("角度", self._rot_spin) col.addLayout(form) btn_row = QHBoxLayout() @@ -148,19 +157,18 @@ class TransformPanel(QWidget): col.addLayout(btn_row) return w - def _make_footer(self) -> QWidget: - footer = QWidget() - footer.setObjectName("transformFooter") - col = QVBoxLayout(footer) - col.setContentsMargins(10, 8, 10, 10) - col.setSpacing(0) + def _emit_reset(self, tag: str): + if tag == "pos": + self.reset_position.emit() + elif tag == "size": + self.reset_size.emit() + elif tag == "rot": + self.reset_rotation.emit() - self._reset_btn = QPushButton("重置位置 / 尺寸 / 角度") - self._reset_btn.setObjectName("transformResetBtn") - self._reset_btn.setToolTip("将印花的位置、尺寸、角度还原为所选模板") - self._reset_btn.clicked.connect(lambda: self.reset_to_template.emit()) - col.addWidget(self._reset_btn) - return footer + def set_resets_enabled(self, enabled: bool): + """Enable/disable the three section reset buttons together.""" + for btn in self._reset_btns.values(): + btn.setEnabled(enabled) @staticmethod def _make_spin(minimum: float, maximum: float, suffix: str) -> QDoubleSpinBox: @@ -178,14 +186,26 @@ class TransformPanel(QWidget): border-bottom: 1px solid #e0e0e0; } #transformSectionHeader { + background-color: #f7f7f7; + border-bottom: 1px solid #eeeeee; + } + #transformSectionTitle { font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; font-weight: bold; color: #555555; - background-color: #f7f7f7; - border-bottom: 1px solid #eeeeee; - padding: 4px 10px; } + #sectionResetBtn { + font-size: 11px; + color: #8a8a8a; + background: transparent; + border: none; + border-radius: 3px; + padding: 2px 6px; + } + #sectionResetBtn:hover { background: #eaf3fc; color: #0078d4; } + #sectionResetBtn:pressed { background: #d6e8f9; } + #sectionResetBtn:disabled { color: #c0c0c0; background: transparent; } #transformForm { background-color: #ffffff; } @@ -207,31 +227,6 @@ class TransformPanel(QWidget): } #rotBtn:hover { background: #e0e0e0; } #rotBtn:pressed { background: #d0d0d0; } - #rotZeroBtn { - font-size: 12px; - padding: 2px 4px; - border: 1px solid #d6d6d6; - border-radius: 3px; - background: #f0f0f0; - color: #444444; - } - #rotZeroBtn:hover { background: #e0e0e0; } - #rotZeroBtn:pressed { background: #d0d0d0; } - #transformFooter { - background-color: #ffffff; - border-bottom: 1px solid #e0e0e0; - } - #transformResetBtn { - font-size: 12px; - font-weight: bold; - color: #0078d4; - background: #ffffff; - border: 1px solid #0078d4; - border-radius: 4px; - padding: 6px 0; - } - #transformResetBtn:hover { background: #eaf3fc; } - #transformResetBtn:pressed { background: #d6e8f9; } """) # ── public API ──────────────────────────────────────────────────────────── @@ -318,9 +313,6 @@ class TransformPanel(QWidget): self._aspect_ratio = w / h self._emit() - def _reset_rotation(self): - self._rot_spin.setValue(0.0) - def _rotate_left(self): self._rot_spin.setValue(self._rot_spin.value() - 90.0) diff --git a/src/services/config_service.py b/src/services/config_service.py index f723741..5b057b8 100644 --- a/src/services/config_service.py +++ b/src/services/config_service.py @@ -9,6 +9,8 @@ DEFAULT_CONFIG = { "output_dir": "", # empty = use app output/ dir "last_garment_dir": "", "last_print_dir": "", + "last_template": "", # name of the last-selected template + "last_batch_mode": "full_combo", # BatchMode value of the last-used mode } _CONFIG_FILENAME = "app_config.json" diff --git a/tasks.md b/tasks.md index 695762a..acd5763 100644 --- a/tasks.md +++ b/tasks.md @@ -435,8 +435,8 @@ - [x] 实现输出格式选择 - [x] 实现质量选择 - [x] 实现导出当前单张 -- [x] 实现应用为模板入口 -- [x] 实现重置为模板入口 +- [x] ~~实现应用为模板入口~~(后移除:与模板区域「另存为」重复,见 17.3) +- [x] ~~实现重置为模板入口~~(后移除:与模板区域「重置为模板」重复,见 17.3) - [x] 显示当前操作影响范围提示 验收: @@ -541,7 +541,74 @@ - [ ] 打包产物可在无 Python 环境的 Windows 电脑启动 - [x] 发布包不包含源代码、缓存、测试输出和私人配置 -## 17. 后续暂缓任务 +## 17. 体验优化(持久化与独立重置) + +第二阶段根据实际使用反馈新增。 + +### 17.1 用户偏好持久化 + +前置阅读: + +- `docs/02-prd.md`(6.6 模板选择、6.9 批量合成导出、9 数据与文件结构) +- `docs/05-project-architecture.md`(4.12 config_service) + +任务: + +- [ ] 先读取 `src/services/config_service.py` 与 `src/app/main_window.py` 现有内容 +- [ ] 在 `main_window` 启动时调用 `load_config()` 并把初值分发给各面板(集中管理,不让控件各自读写) +- [ ] 在 `config_service` 默认配置中新增 `last_template`、`last_batch_mode` +- [ ] 记住并在启动时恢复上次选择的模板;自定义模板已删除时回退到第一个内置模板 +- [ ] 记住并在启动时恢复上次选择的批量模式;持久化值非法时回退默认(全组合) +- [ ] 面板选择变化时「改一次存一次」回写配置 +- [ ] `template_panel` 增加模板选择变化信号(携带模板名) +- [ ] `queue_panel` 增加 `set_batch_mode()` 与批量模式变化信号 + +验收: + +- [ ] 重启后自动恢复上次选择的模板和批量模式 +- [ ] 自定义模板被删除或批量模式值非法时安全回退,不报错 +- [ ] UI 控件不直接读写配置文件 + +### 17.2 位置 / 尺寸 / 旋转独立重置 + +前置阅读: + +- `docs/07-ui-design.md`(7 右侧参数栏、10 视觉规范) +- `docs/08-image-editor-design.md` + +任务: + +- [ ] 先读取 `src/app/widgets/transform_panel.py` 现有内容 +- [ ] 在 `位置` / `尺寸` / `旋转` 三个分区标题右侧各加一个独立 `重置` 按钮 +- [ ] 各重置只把对应参数还原为当前选中模板的值,互不影响(位置→仅 X/Y;尺寸→仅宽/高,位置不变;旋转→仅角度) +- [ ] 移除合并的 `重置位置 / 尺寸 / 角度` 按钮与角度旁的 `归零` 按钮 +- [ ] 重置按钮采用幽灵样式(静止灰、悬停蓝、按下深蓝、禁用更灰),不使用红色 +- [ ] 未选模板或未加载衣服/印花时三个按钮置灰 +- [ ] 由 `main_window` 取模板换算值并按项合并进当前 `TransformState` + +验收: + +- [ ] 位置、尺寸、旋转可分别独立重置,互不影响 +- [ ] 尺寸重置只改变宽 / 高,不改变位置 X / Y +- [ ] 按钮视觉层级符合 `docs/07-ui-design.md` 第 10 节 + +### 17.3 按钮去重(导出面板模板入口) + +前置阅读: + +- `docs/07-ui-design.md`(7.1 模板区域、7.7 操作按钮) + +任务: + +- [ ] 移除导出面板的 `应用为模板` 与 `重置为模板` 按钮(与模板区域的 `另存为` / `重置为模板` 是同一动作的重复入口) +- [ ] 模板相关操作统一留在模板区域,导出面板只负责输出与导出 + +验收: + +- [ ] 同一个动作在界面上只出现一次,命名一致 +- [ ] 导出面板仅保留输出设置与 `导出当前单张` + +## 18. 后续暂缓任务 以下任务第一阶段暂不做,后续需要时再新增设计文档: