From 1bef9631eba93c511e598b6a623ebe4c5913d796 Mon Sep 17 00:00:00 2001 From: LiXin01 <1625205083@qq.com> Date: Fri, 9 Oct 2026 16:09:27 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=B2=E7=82=B9=E8=8F=9C=E5=93=81=E6=A0=8F?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dao/meal_schedule.py | 18 +++ ui/classifier_ui.py | 289 +++++++++++++++++++++++++++++++++---------- 2 files changed, 243 insertions(+), 64 deletions(-) diff --git a/dao/meal_schedule.py b/dao/meal_schedule.py index 2f8b564..fa88dac 100644 --- a/dao/meal_schedule.py +++ b/dao/meal_schedule.py @@ -144,6 +144,24 @@ class MealScheduleStore: return False return name in self.today_dish_names() + def lookup_price(self, dish_name): + """按菜名查今日排餐价格;未命中返回 None。""" + name = (dish_name or '').strip() + if not name: + return None + today = datetime.now().strftime('%Y-%m-%d') + with self._lock: + raw = self._raw + date = self._date + if not raw or date != today: + return None + for base in raw.get('bases') or []: + for meal in base.get('meals') or []: + for dish in meal.get('dishes') or []: + if (dish.get('name') or '').strip() == name: + return dish.get('price') + return None + def current_period(self, now=None): """ 根据当前时间匹配餐段。 diff --git a/ui/classifier_ui.py b/ui/classifier_ui.py index edcd499..1df97cc 100644 --- a/ui/classifier_ui.py +++ b/ui/classifier_ui.py @@ -134,6 +134,7 @@ class BaseDishApp(PendingLearningUIMixin): self._order_locked = False # 报过一单后锁定,清场后才允许下一单 self._order_clear_since = None # 无盘连续计时起点 self._order_clear_hold = 1.5 # 无盘持续多久才解锁(秒) + self._cart_hold = False # 本盘清单已展示:清场前不刷新 self._last_detect_fp = None # 上一帧指纹(静止画面跳过检测) self._last_live_dishes = None # 上一轮识别结果(跳过检测时推进稳定计时) self._frame_sim_threshold = 0.992 # 帧相似度阈值:非常相似则跳过 YOLO/分类 @@ -304,11 +305,15 @@ class BaseDishApp(PendingLearningUIMixin): def _on_wheel_all(self, event): alive = [] + menu_cv = getattr(self, "_menu_canvas", None) for canvas in list(getattr(self, "_scroll_canvases", [])): try: if not canvas.winfo_exists(): continue alive.append(canvas) + # 今日菜单为空时禁止滚动 + if menu_cv is not None and canvas is menu_cv and not getattr(self, "_menu_scroll_enabled", True): + continue x, y = canvas.winfo_rootx(), canvas.winfo_rooty() if x <= event.x_root <= x + canvas.winfo_width() and \ y <= event.y_root <= y + canvas.winfo_height(): @@ -1297,38 +1302,120 @@ class BaseDishApp(PendingLearningUIMixin): self._sync_dialog = None tk.Frame(self.root, bg=c["border"], height=1).pack(fill=tk.X) - def _build_today_menu(self, parent, c): - """左侧:第三方接口拉取的今日排餐菜单。""" - panel = tk.Frame( + def _build_left_panel(self, parent, c): + """左侧大框:已点菜品 / 今日菜单 同级页签切换。""" + shell = tk.Frame( parent, bg=c["surface"], highlightbackground=c["border"], highlightthickness=1, ) - panel.pack(fill=tk.X, pady=(0, 8)) + shell.pack(fill=tk.BOTH, expand=True) - head = tk.Frame(panel, bg=c["surface"]) - head.pack(fill=tk.X, padx=12, pady=(10, 4)) - tk.Label( - head, text="今日菜单", bg=c["surface"], fg=c["text"], font=FONTS["h1"], - ).pack(side=tk.LEFT) - PillButton( - head, text="刷新", variant="ghost", size="sm", + tab_row = tk.Frame(shell, bg=c["surface"]) + tab_row.pack(fill=tk.X, padx=10, pady=(10, 6)) + + tabs = tk.Frame(tab_row, bg=c["surface"]) + tabs.pack(side=tk.LEFT) + self._left_tab = "cart" + self._left_tab_btns = {} + self._left_tab_cart_btn = PillButton( + tabs, text="已点菜品", variant="primary", size="sm", + command=lambda: self._switch_left_tab("cart"), + ) + self._left_tab_cart_btn.pack(side=tk.LEFT, padx=(0, 6)) + self._left_tab_menu_btn = PillButton( + tabs, text="今日菜单", variant="ghost", size="sm", + command=lambda: self._switch_left_tab("menu"), + ) + self._left_tab_menu_btn.pack(side=tk.LEFT) + self._left_tab_btns = { + "cart": self._left_tab_cart_btn, + "menu": self._left_tab_menu_btn, + } + + # 右侧:随页签切换的操作区 + self._left_tab_actions = tk.Frame(tab_row, bg=c["surface"]) + self._left_tab_actions.pack(side=tk.RIGHT) + self.cart_count_label = tk.Label( + self._left_tab_actions, text="0 项", bg=c["surface"], fg=c["muted"], + font=FONTS["small"], + ) + self._menu_refresh_btn = PillButton( + self._left_tab_actions, text="刷新", variant="ghost", size="sm", command=self.refresh_today_menu_async, tooltip="重新从管理系统拉取今日排餐", - ).pack(side=tk.RIGHT) + ) + + body = tk.Frame(shell, bg=c["surface"]) + body.pack(fill=tk.BOTH, expand=True) + self._left_body = body + + self._cart_page = tk.Frame(body, bg=c["surface"]) + self._menu_page = tk.Frame(body, bg=c["surface"]) + self._build_pos_cart(self._cart_page, c) + self._build_today_menu(self._menu_page, c) + self._switch_left_tab("cart") + return shell + + def _switch_left_tab(self, which): + """切换左侧页签:cart=已点菜品,menu=今日菜单。""" + which = "menu" if which == "menu" else "cart" + self._left_tab = which + cart_page = getattr(self, "_cart_page", None) + menu_page = getattr(self, "_menu_page", None) + if cart_page is None or menu_page is None: + return + if which == "cart": + menu_page.pack_forget() + cart_page.pack(fill=tk.BOTH, expand=True) + else: + cart_page.pack_forget() + menu_page.pack(fill=tk.BOTH, expand=True) + + for key, btn in (getattr(self, "_left_tab_btns", {}) or {}).items(): + try: + btn.set_variant("primary" if key == which else "ghost") + except Exception: + try: + btn.configure(variant="primary" if key == which else "ghost") + except Exception: + pass + + # 操作区:已点显示数量,菜单显示刷新 + try: + self.cart_count_label.pack_forget() + except Exception: + pass + try: + self._menu_refresh_btn.pack_forget() + except Exception: + pass + if which == "cart": + try: + self.cart_count_label.pack(side=tk.RIGHT) + except Exception: + pass + else: + try: + self._menu_refresh_btn.pack(side=tk.RIGHT) + except Exception: + pass + + def _build_today_menu(self, parent, c): + """今日排餐内容(嵌入左侧页签页,不再单独外框)。""" + panel = parent self.menu_meta_label = tk.Label( panel, text="正在加载今日菜单…", bg=c["surface"], fg=c["muted"], font=FONTS["small"], anchor="w", ) - self.menu_meta_label.pack(fill=tk.X, padx=12, pady=(0, 4)) + self.menu_meta_label.pack(fill=tk.X, padx=12, pady=(4, 4)) # 餐段切换 self.menu_period_bar = tk.Frame(panel, bg=c["surface"]) self.menu_period_bar.pack(fill=tk.X, padx=8, pady=(0, 4)) - list_host = tk.Frame(panel, bg=c["surface"], height=148) - list_host.pack(fill=tk.X, padx=4, pady=(0, 8)) - list_host.pack_propagate(False) + list_host = tk.Frame(panel, bg=c["surface"]) + list_host.pack(fill=tk.BOTH, expand=True, padx=4, pady=(0, 8)) wrap, self.menu_dish_frame, self._menu_canvas = self._make_scroll_area( list_host, c["surface"], ) @@ -1338,6 +1425,7 @@ class BaseDishApp(PendingLearningUIMixin): self._menu_selected_period = None # None=自动当前餐段 self._menu_period_btns = [] self._menu_refresh_job = None + self._menu_scroll_enabled = False return panel def refresh_today_menu_async(self): @@ -1442,7 +1530,7 @@ class BaseDishApp(PendingLearningUIMixin): if not periods: EmptyState( self.menu_dish_frame, text="暂无今日菜单", - hint="启动后会自动从管理系统拉取;也可点右上角「刷新」", + hint="启动后会自动从管理系统拉取;也可点页签旁「刷新」", glyph="◎", bg=c["surface"], pady=16, ).pack(fill=tk.X) elif not dishes: @@ -1478,16 +1566,24 @@ class BaseDishApp(PendingLearningUIMixin): row, text=name, bg=bg, fg=c["text"], font=FONTS["ui"], anchor="w", ).pack(side=tk.LEFT, padx=(8, 4), pady=4) - if price_txt: - tk.Label( - row, text=price_txt, bg=bg, fg=c["muted"], font=FONTS["small"], - ).pack(side=tk.RIGHT, padx=8) + tk.Label( + row, text=price_txt or "—", bg=bg, + fg=c["text"] if price_txt else c["muted"], + font=FONTS["small"], + ).pack(side=tk.RIGHT, padx=8) + # 有菜才允许滚动;空菜单锁死滚动区域,避免空转 try: self._menu_canvas.update_idletasks() - self._menu_canvas.configure(scrollregion=self._menu_canvas.bbox("all")) + if dishes: + self._menu_canvas.configure(scrollregion=self._menu_canvas.bbox("all")) + self._menu_scroll_enabled = True + else: + self._menu_canvas.yview_moveto(0) + self._menu_canvas.configure(scrollregion=(0, 0, 0, 0)) + self._menu_scroll_enabled = False except Exception: - pass + self._menu_scroll_enabled = bool(dishes) # 每分钟刷新一次(餐段切换 / 元信息) job = getattr(self, "_menu_refresh_job", None) @@ -1506,25 +1602,25 @@ class BaseDishApp(PendingLearningUIMixin): self.refresh_today_menu() def _build_pos_cart(self, parent, c): - panel = tk.Frame(parent, bg=c["surface"], highlightbackground=c["border"], highlightthickness=1) - panel.pack(fill=tk.BOTH, expand=True) - - head = tk.Frame(panel, bg=c["surface"]) - head.pack(fill=tk.X, padx=12, pady=(12, 8)) - tk.Label(head, text="已点菜品", bg=c["surface"], fg=c["text"], - font=FONTS["h1"]).pack(side=tk.LEFT) - self.cart_count_label = tk.Label(head, text="0 项", bg=c["surface"], fg=c["muted"], - font=FONTS["small"]) - self.cart_count_label.pack(side=tk.RIGHT) + """已点菜品内容(嵌入左侧页签页,标题由页签承担)。""" + panel = parent cols = tk.Frame(panel, bg=c["pos_row_alt"]) cols.pack(fill=tk.X) - for text, w in (("菜品", 22), ("数量", 10), ("", 4)): + for text, w in (("菜品", 14), ("金额", 8), ("数量", 8), ("", 4)): tk.Label(cols, text=text, bg=c["pos_row_alt"], fg=c["muted"], font=FONTS["small"], width=w, anchor="w").pack(side=tk.LEFT, padx=4, pady=6) wrap, self.cart_frame, self._cart_canvas = self._make_scroll_area(panel, c["surface"]) wrap.pack(fill=tk.BOTH, expand=True, padx=4, pady=4) + + foot = tk.Frame(panel, bg=c["surface"]) + foot.pack(fill=tk.X, padx=12, pady=(0, 10)) + self.cart_total_label = tk.Label( + foot, text="合计 ¥0", bg=c["surface"], fg=c["text"], + font=FONTS["ui_bold"], anchor="e", + ) + self.cart_total_label.pack(side=tk.RIGHT) return panel def _build_pos_preview(self, parent, c): @@ -1696,8 +1792,7 @@ class BaseDishApp(PendingLearningUIMixin): left = tk.Frame(body, bg=c["bg"], width=SIZES.get("cart_w", 320)) left.grid(row=0, column=0, sticky="nsew", padx=(0, 8)) left.grid_propagate(False) - self._build_today_menu(left, c) - self._build_pos_cart(left, c) + self._build_left_panel(left, c) right = tk.Frame(body, bg=c["bg"]) right.grid(row=0, column=1, sticky="nsew") @@ -1975,16 +2070,15 @@ class BaseDishApp(PendingLearningUIMixin): self._order_locked = False self._order_clear_since = None self._last_reported_sig = None + self._cart_hold = False def _note_order_scene_busy(self): """画面上仍有托盘/已识别菜:清场计时清零。""" self._order_clear_since = None def _note_order_scene_clear(self): - """无餐盘:累计清场时间,满 hold 后解除订单锁定。""" - if not self._order_locked: - self._order_clear_since = None - return False + """无餐盘:累计清场时间,满 hold 后解除订单锁定,并允许下一盘刷新清单。""" + # 无上报锁定时也要能「松开清单」:无盘持续后允许下一盘覆盖已点菜品 now = time.time() if self._order_clear_since is None: self._order_clear_since = now @@ -1992,10 +2086,15 @@ class BaseDishApp(PendingLearningUIMixin): hold = float(getattr(self, "_order_clear_hold", 1.5) or 1.5) if now - self._order_clear_since < hold: return False + was_locked = bool(self._order_locked) self._order_locked = False self._order_clear_since = None self._last_reported_sig = None - print(f"[order] 清场解锁(无盘持续 ≥ {hold:g}s,允许下一单)") + self._cart_hold = False # 下一盘识别到目标时可更换清单 + if was_locked: + print(f"[order] 清场解锁(无盘持续 ≥ {hold:g}s,允许下一单)") + else: + print(f"[order] 清场,已点清单可被下一盘覆盖(无盘 ≥ {hold:g}s)") return True def _try_live_report(self, save_path, known_dishes, preview): @@ -2451,15 +2550,15 @@ class BaseDishApp(PendingLearningUIMixin): def _no_plate(did_unlock=unlocked): self._live_overlay_dishes = [] - self.render_result_dock([], None) - tip = "扫描中:未检测到餐盘" + # 无盘不刷空清单:保留上一盘已点菜品,直到下一盘识别到目标 + tip = "扫描中:未检测到餐盘|左侧保留上一盘" if did_unlock: tip += "|已清场,可报下一单" self._set_result_text(tip, "warn") if hasattr(self, "scan_tip_label"): try: self.scan_tip_label.config( - text="扫描中 · 请将餐盘放入画面中央" + text="扫描中 · 请放入下一盘(已点清单暂留)" + (" · 已解锁下一单" if did_unlock else "") ) except Exception: @@ -2518,9 +2617,9 @@ class BaseDishApp(PendingLearningUIMixin): written=pending_written, elapsed=stable_elapsed, committed=already_committed, hold_s=hold, ): - # 只更新叠加框 + 左侧清单;预览继续刷实时帧 + # 只更新叠加框;清单:本盘展示后冻结,清场后下一盘再换 self._live_overlay_dishes = list(ds or []) - self.render_result_dock(ds, path) + self.render_result_dock(ds, path, live_hold=True) try: self.update_pending_btn() except Exception: @@ -2529,13 +2628,14 @@ class BaseDishApp(PendingLearningUIMixin): known = [d for d in ds if not d.get("is_unknown")] unknown = [d for d in ds if d.get("is_unknown")] - if known: - top = max(known, key=lambda d: d.get("score") or 0) - name = top.get("label") or "菜品" - msg = f"扫描中|已识别 {len(known)}|{name}" + if known or unknown: + msg = f"扫描中|清单 {len(ds)} 项" + if known: + top = max(known, key=lambda d: d.get("score") or 0) + msg += f"|{top.get('label') or '菜品'}" if unknown: - msg += f"|未知 {len(unknown)}(不入清单)" - self._set_result_text(msg, "ok" if not unknown else "warn") + msg += f"|未知 {len(unknown)}" + self._set_result_text(msg, "ok" if known else "warn") else: self._set_result_text( txt or f"扫描中|检出 {len(ds)} 盘,尚无达到阈值的菜品", @@ -2571,7 +2671,7 @@ class BaseDishApp(PendingLearningUIMixin): ) else: self.scan_tip_label.config( - text="实时扫描中 · 仅已识别菜计入左侧清单" + text="实时扫描中 · 下一盘识别后刷新已点清单" ) except Exception: pass @@ -2590,6 +2690,7 @@ class BaseDishApp(PendingLearningUIMixin): self._cart = [] self._last_dishes = [] self._live_overlay_dishes = [] + self._cart_hold = False self.render_cart() if not self._preview_enabled and not self._live_enabled and hasattr(self, "img_label"): self.img_label.config( @@ -2599,23 +2700,40 @@ class BaseDishApp(PendingLearningUIMixin): self.img_label.image = None self._set_result_text("已清空本次已点菜品", "ok") + def _format_price_text(self, price): + if isinstance(price, (int, float)): + return f"¥{price:g}" + if price not in (None, ""): + return f"¥{price}" + return "—" + def fill_cart_from_dishes(self, dishes): - """左侧清单只放已识别菜;未知仅留在预览标注 / 待学习流程。""" + """写入左侧清单:已识别 + 未知;金额按今日菜单价查询。""" cart = [] for d in dishes or []: - if d.get("is_unknown"): - continue - name = d.get("label") or d.get("name") or "菜品" + is_unk = bool(d.get("is_unknown")) + if is_unk: + sug = (d.get("suggested_category") or "").strip() + name = f"未知·{sug}" if sug else "未知" + price = None + else: + name = d.get("label") or d.get("name") or "菜品" + try: + price = self.meal_schedule.lookup_price(name) + except Exception: + price = None cart.append({ "index": d.get("index"), "name": name, "label": d.get("label"), "score": d.get("score"), - "is_unknown": False, + "is_unknown": is_unk, "qty": 1, + "price": price, "crop_image": d.get("crop_image"), "pending_id": d.get("pending_id"), "suggested_category": d.get("suggested_category"), + "in_today_menu": bool(d.get("in_today_menu")), }) self._cart = cart self._last_dishes = dishes or [] @@ -2641,10 +2759,9 @@ class BaseDishApp(PendingLearningUIMixin): self._cart_thumbs = [] if not self._cart: - hold_s = float(getattr(self, "_live_stable_seconds", DISH_LIVE_PENDING_STABLE_SECONDS) or 1.0) EmptyState( - self.cart_frame, text="暂无已识别菜品", - hint=f"未知菜不入清单;画面稳定约 {hold_s:g} 秒后记入待学习", + self.cart_frame, text="暂无已点菜品", + hint="下一盘出现并识别到目标后刷新;未知菜也会显示", glyph="◍", bg=c["surface"], pady=28, ).pack(fill=tk.X) else: @@ -2669,7 +2786,8 @@ class BaseDishApp(PendingLearningUIMixin): info = tk.Frame(pad, bg=bg) info.pack(side=tk.LEFT, fill=tk.X, expand=True) - tk.Label(info, text=item.get("name") or "菜品", bg=bg, fg=c["text"], + name_fg = c["warn"] if item.get("is_unknown") else c["text"] + tk.Label(info, text=item.get("name") or "菜品", bg=bg, fg=name_fg, font=FONTS["ui_bold"], anchor="w").pack(fill=tk.X) score = item.get("score") meta = f"#{item.get('index') or i + 1}" @@ -2677,9 +2795,18 @@ class BaseDishApp(PendingLearningUIMixin): meta += f" · {score:.2f}" if item.get("is_unknown"): meta += " · 待确认" + elif item.get("in_today_menu"): + meta += " · 今日菜单" tk.Label(info, text=meta, bg=bg, fg=c["muted"], font=FONTS["small"], anchor="w").pack(fill=tk.X) + price_txt = self._format_price_text(item.get("price")) + tk.Label( + pad, text=price_txt, bg=bg, + fg=c["text"] if price_txt != "—" else c["muted"], + font=FONTS["ui"], width=6, anchor="e", + ).pack(side=tk.LEFT, padx=(4, 2)) + qty_box = tk.Frame(pad, bg=bg) qty_box.pack(side=tk.LEFT, padx=6) PillButton(qty_box, text="−", variant="ghost", size="sm", @@ -2693,11 +2820,45 @@ class BaseDishApp(PendingLearningUIMixin): if hasattr(self, "cart_count_label"): self.cart_count_label.config(text=f"{len(self._cart)} 项") + if hasattr(self, "cart_total_label"): + total = 0.0 + has_price = False + for item in self._cart: + p = item.get("price") + q = int(item.get("qty") or 1) + if isinstance(p, (int, float)): + total += float(p) * q + has_price = True + self.cart_total_label.config( + text=f"合计 ¥{total:g}" if has_price else "合计 —" + ) - def render_result_dock(self, dishes, labeled_path=None): - """兼容旧调用:识别结果写入购物车。""" + def render_result_dock(self, dishes, labeled_path=None, *, live_hold=False): + """识别结果写入购物车。 + + live_hold=True(实时扫描): + - 无目标:不改清单 + - 本盘已展示过(_cart_hold):不因标签抖动刷新 + - 清场后下一盘识别到目标:才更换清单 + """ + if live_hold: + if not dishes: + return + if getattr(self, "_cart_hold", False): + return + self.fill_cart_from_dishes(dishes) + self.render_cart() + self._cart_hold = True + # 新一盘写入清单时自动切到「已点菜品」 + try: + if getattr(self, "_left_tab", None) != "cart": + self._switch_left_tab("cart") + except Exception: + pass + return self.fill_cart_from_dishes(dishes) self.render_cart() + self._cart_hold = False # --------------------------------------------------------------- # 模板详情 / 删除