diff --git a/ui/classifier_ui.py b/ui/classifier_ui.py index 1df97cc..69af3bc 100644 --- a/ui/classifier_ui.py +++ b/ui/classifier_ui.py @@ -1922,6 +1922,7 @@ class BaseDishApp(PendingLearningUIMixin): stats.config(text=f"筛选 0 / {visible_n} 个菜品") else: stats.config(text="共 0 个菜品 · 0 张模板") + self._scroll_template_list_top() return dish_n = len(matched) @@ -2026,6 +2027,22 @@ class BaseDishApp(PendingLearningUIMixin): text=f"共 {dish_n} 个菜品 · {total_tpl} 张模板 · 本页 {len(page_items)} 条" ) + # 翻页 / 刷新后滚回列表顶部 + self._scroll_template_list_top() + + def _scroll_template_list_top(self): + canvas = getattr(self, "_template_canvas", None) + if not self._widget_alive(canvas): + return + try: + canvas.update_idletasks() + canvas.yview_moveto(0) + bbox = canvas.bbox("all") + if bbox: + canvas.configure(scrollregion=bbox) + except Exception: + pass + # --------------------------------------------------------------- # 实时视频:流畅预览 + 约 1fps 识别(框叠加) # --------------------------------------------------------------- @@ -2868,53 +2885,109 @@ class BaseDishApp(PendingLearningUIMixin): if not template_details: messagebox.showinfo("提示", "该类别暂无模板信息") return - detail_win = self._dialog_shell(f"模板详情 - {part_type}", 1000, 780) + detail_win = self._dialog_shell(f"模板详情 - {part_type}", 1040, 760) detail_win._detail_photos = [] # 防止 PhotoImage 被回收 - tk.Label(detail_win, text=f"{part_type} · 共 {len(template_details)} 张模板", - bg=COLORS["bg"], fg=COLORS["text"], font=FONTS["h1"], - ).pack(pady=(14, 8), padx=16, anchor=tk.W) + head = tk.Frame(detail_win, bg=COLORS["bg"]) + head.pack(fill=tk.X, padx=16, pady=(14, 6)) + tk.Label( + head, text=f"{part_type}", + bg=COLORS["bg"], fg=COLORS["text"], font=FONTS["h1"], + ).pack(side=tk.LEFT) + tk.Label( + head, text=f"共 {len(template_details)} 张 · 点击缩略图可放大", + bg=COLORS["bg"], fg=COLORS["muted"], font=FONTS["small"], + ).pack(side=tk.LEFT, padx=(10, 0), pady=(6, 0)) wrap, inner, _cv = self._make_scroll_area(detail_win, COLORS["bg"]) wrap.pack(fill=tk.BOTH, expand=True, padx=14, pady=(0, 8)) - for idx, temp in enumerate(template_details, 1): - card = RoundCard(inner, title=f"模板 {idx}", radius=RADIUS["lg"], padding=10) - card.pack(fill=tk.X, pady=6) - if card.header is not None: - PillButton( - card.header, text="删除此模板", variant="danger", size="sm", - command=lambda p=part_type, i=idx - 1: self.delete_single_template(p, i), - ).pack(side=tk.RIGHT) - tk.Label(card.body, text=f"添加时间:{temp.get('add_time') or '—'}", - bg=COLORS["surface"], fg=COLORS["text_body"], font=FONTS["ui"], - anchor="w").pack(fill=tk.X) - tk.Label(card.body, text=f"文件路径:{temp.get('path') or ''}", - bg=COLORS["surface"], fg=COLORS["muted"], font=FONTS["small"], - anchor="w", wraplength=860, justify=tk.LEFT).pack(fill=tk.X, pady=(2, 6)) + # 统一网格缩略图,避免首张超大、后面挤在一起 + cols = 4 + cell_w, cell_h = 220, 200 + grid = tk.Frame(inner, bg=COLORS["bg"]) + grid.pack(fill=tk.BOTH, expand=True, padx=4, pady=4) + for col in range(cols): + grid.columnconfigure(col, weight=1, minsize=cell_w + 16) - # 首张大图,其余只显示小图,避免一次解码过多大图 + def _preview_large(path, title): + if not path or not os.path.isfile(str(path)): + messagebox.showwarning("提示", "图片不存在", parent=detail_win) + return + prev = self._dialog_shell(title or "模板预览", 920, 700) + prev.transient(detail_win) + try: + with open(path, "rb") as f: + img = Image.open(f).convert("RGB") + img.thumbnail((880, 620), Image.Resampling.LANCZOS) + photo = ImageTk.PhotoImage(img) + prev._photo = photo + tk.Label(prev, image=photo, bg=COLORS["bg"]).pack(padx=12, pady=12) + tk.Label( + prev, text=os.path.basename(str(path)), + bg=COLORS["bg"], fg=COLORS["muted"], font=FONTS["small"], + ).pack() + except Exception as exc: + tk.Label( + prev, text=f"加载失败:{exc}", + bg=COLORS["bg"], fg=COLORS["danger"], font=FONTS["ui"], + ).pack(pady=40) + PillButton(prev, text="关闭", variant="ghost", command=prev.destroy).pack(pady=(8, 14)) + + for idx, temp in enumerate(template_details): + r, c = divmod(idx, cols) + cell = tk.Frame( + grid, bg=COLORS["surface"], + highlightbackground=COLORS["border"], highlightthickness=1, + ) + cell.grid(row=r, column=c, padx=6, pady=6, sticky="nsew") + pad = tk.Frame(cell, bg=COLORS["surface"]) + pad.pack(fill=tk.BOTH, expand=True, padx=8, pady=8) + + img_path = temp.get("path") or temp.get("thumbnail") + thumb_holder = tk.Frame(pad, bg=COLORS["surface_alt"], width=cell_w, height=140) + thumb_holder.pack(fill=tk.X) + thumb_holder.pack_propagate(False) try: - img_path = temp.get("path") if not img_path or not os.path.isfile(str(img_path)): raise FileNotFoundError("图片不存在") with open(img_path, "rb") as f: img = Image.open(f).convert("RGB") - if idx == 1: - img.thumbnail((860, 520), Image.Resampling.LANCZOS) - else: - img.thumbnail((220, 160), Image.Resampling.LANCZOS) + img.thumbnail((cell_w, 140), Image.Resampling.LANCZOS) photo = ImageTk.PhotoImage(img) detail_win._detail_photos.append(photo) - lbl = tk.Label(card.body, image=photo, bg=COLORS["surface"], bd=0) + lbl = tk.Label(thumb_holder, image=photo, bg=COLORS["surface_alt"], bd=0, cursor="hand2") lbl.image = photo - lbl.pack() + lbl.pack(expand=True) + lbl.bind( + "", + lambda e, p=img_path, t=f"{part_type} · 模板 {idx + 1}": _preview_large(p, t), + ) except Exception as e: - tk.Label(card.body, text=f"图片加载失败:{e}", bg=COLORS["surface"], - fg=COLORS["danger"], font=FONTS["small"]).pack(pady=4) + tk.Label( + thumb_holder, text=f"加载失败\n{e}", + bg=COLORS["surface_alt"], fg=COLORS["danger"], + font=FONTS["caption"], justify=tk.CENTER, + ).pack(expand=True) - PillButton(detail_win, text="关闭", variant="ghost", - command=detail_win.destroy).pack(pady=(0, 12)) + meta = tk.Frame(pad, bg=COLORS["surface"]) + meta.pack(fill=tk.X, pady=(8, 0)) + tk.Label( + meta, text=f"#{idx + 1}", bg=COLORS["surface"], fg=COLORS["text"], + font=FONTS["ui_bold"], + ).pack(side=tk.LEFT) + tk.Label( + meta, text=temp.get("add_time") or "—", + bg=COLORS["surface"], fg=COLORS["muted"], font=FONTS["caption"], + ).pack(side=tk.LEFT, padx=(8, 0)) + PillButton( + meta, text="删除", variant="danger", size="sm", + command=lambda p=part_type, i=idx: self.delete_single_template(p, i), + ).pack(side=tk.RIGHT) + + foot = tk.Frame(detail_win, bg=COLORS["bg"]) + foot.pack(fill=tk.X, padx=16, pady=(0, 12)) + PillButton(foot, text="关闭", variant="ghost", command=detail_win.destroy).pack(side=tk.RIGHT) def center_window(self, window, width, height): try: diff --git a/ui/widgets.py b/ui/widgets.py index 7335d44..b39548a 100644 --- a/ui/widgets.py +++ b/ui/widgets.py @@ -408,19 +408,22 @@ class PillButton(tk.Frame): if self._disabled: return c["disabled_bg"], "#FFFFFF", c["disabled_bg"], False if self._variant == "primary": + # 按下比悬停更深,反馈更明显 if self._pressed: - return c["primary_dark"], "#FFFFFF", c["primary_dark"], True + return "#1A6B45", "#FFFFFF", "#1A6B45", False if self._hover: return c["primary_dark"], "#FFFFFF", c["primary_dark"], True return c["primary"], "#FFFFFF", c["primary"], True if self._variant == "secondary": if self._pressed: - return c["primary_border"], c["primary_dark"], c["primary"], True + return c["primary"], "#FFFFFF", c["primary"], False if self._hover: return c["primary_soft"], c["primary_dark"], c["primary"], True return c["surface"], c["primary"], c["primary_border"], True if self._variant == "danger": - if self._hover or self._pressed: + if self._pressed: + return "#9B1C1C", "#FFFFFF", "#9B1C1C", False + if self._hover: return c["danger_dark"], "#FFFFFF", c["danger_dark"], False return c["surface"], c["danger"], c["danger_border"], False # ghost @@ -447,8 +450,15 @@ class PillButton(tk.Frame): self._canvas.configure(width=w + 2, height=h + 2, bg=self["bg"]) if shadow and not self._disabled and not self._pressed: _round_rect(self._canvas, 2, 2, w + 1, h + 1, r=RADIUS["md"], fill=COLORS["shadow"], outline="") - offset = 1 if (self._pressed and not self._disabled) else 0 - _round_rect(self._canvas, 0, offset, w - 1, h - 1 + offset, r=RADIUS["md"], fill=fill, outline=border) + # 按下时整体下沉 + 略缩小,点击感更清晰 + offset = 2 if (self._pressed and not self._disabled) else 0 + inset = 1 if (self._pressed and not self._disabled) else 0 + _round_rect( + self._canvas, + inset, offset + inset, + w - 1 - inset, h - 1 + offset - inset, + r=RADIUS["md"], fill=fill, outline=border, + ) self._canvas.create_text(w // 2, h // 2 + offset, text=label, fill=fg, font=font) def _on_enter(self, _e=None): @@ -467,8 +477,9 @@ class PillButton(tk.Frame): return self._pressed = True self._draw() + self._canvas.update_idletasks() - def _on_release(self, _e=None): + def _on_release(self, event=None): if self._disabled or not self._command: self._pressed = False self._draw() @@ -476,8 +487,29 @@ class PillButton(tk.Frame): was = self._pressed self._pressed = False self._draw() - if was: - self._command() + if not was: + return + # 松手仍在按钮内才触发,避免拖出后误点 + try: + x = getattr(event, "x", 0) + y = getattr(event, "y", 0) + w = max(self._canvas.winfo_width(), 1) + h = max(self._canvas.winfo_height(), 1) + if not (0 <= x <= w and 0 <= y <= h): + return + except Exception: + pass + cmd = self._command + # 延后一拍执行,先完成按下回弹绘制;弹窗打开时也不卡死视觉态 + self.after(1, lambda: self._safe_fire(cmd)) + + def _safe_fire(self, cmd): + if self._disabled or not cmd: + return + try: + cmd() + except Exception as exc: + print(f"[PillButton] command failed: {exc}") class IconButton(tk.Label):