From 2bf6d2c47fd9935784b00bc13eb871f414e162fb Mon Sep 17 00:00:00 2001 From: wanghanbo <1411232085@qq.com> Date: Mon, 12 Oct 2026 12:26:03 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=BE=85=E5=AD=A6=E4=B9=A0?= =?UTF-8?q?=E5=BA=93=EF=BC=8C=E5=92=8C=E5=88=A4=E6=96=AD=E8=BF=90=E5=8A=A8?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=EF=BC=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/classifier_ui.py | 179 ++++++++++++++++++++++++++++++-------- ui/pending_learning_ui.py | 164 +++++++++++++++++++++++++++++----- 2 files changed, 283 insertions(+), 60 deletions(-) diff --git a/ui/classifier_ui.py b/ui/classifier_ui.py index 201f117..7c278df 100644 --- a/ui/classifier_ui.py +++ b/ui/classifier_ui.py @@ -131,8 +131,9 @@ class BaseDishApp(PendingLearningUIMixin): self._live_stable_committed_sig = None self._live_stable_snapshot = None # 稳定期间保留的 dishes,供写入 self._last_reported_sig = None # 实时上报去重:相同结果只报一次 - self._last_detect_fp = None # 识别后锚点指纹(静止等待运动) + self._last_detect_fp = None # 识别后锚点指纹(静止等待运动;仅餐盘区域) self._last_live_dishes = None # 上一轮识别结果(跳过检测时推进稳定计时) + self._last_motion_boxes = None # 上一轮餐盘框,运动判定只看菜品区域 self._frame_sim_threshold = 0.992 # 帧相似度阈值:判定静止 / 运动 # 运动状态机:动→静保持 still_hold 秒 → YOLO 一次 → 静待运动 → 再静… self._live_still_hold_seconds = float(DISH_LIVE_STILL_HOLD_SECONDS) @@ -194,9 +195,10 @@ class BaseDishApp(PendingLearningUIMixin): return None def take_live_frame(self): - """抓取实时帧;返回 (0, path, frame) 或 (-1, None, None)。 + """抓取实时帧(默认不落盘)。返回 (0, None, frame) 或 (-1, None, None)。 - 优先复用预览缓存帧,避免与预览争抢相机;磁盘路径供上报/待学习。 + 优先复用预览缓存帧,避免与预览争抢相机。 + 有订单时再调用 _save_order_image 写入 images/。 """ frame = None with self._live_lock: @@ -209,19 +211,25 @@ class BaseDishApp(PendingLearningUIMixin): frame = self.grab_preview_frame() if frame is None: return -1, None, None + return 0, None, frame + + def _save_order_image(self, frame_bgr): + """有一单时落盘一张整帧到 images/;成功返回绝对路径,失败返回 None。""" + if frame_bgr is None: + return None os.makedirs("images", exist_ok=True) stamp = int(time.time() * 1000) save_path = os.path.abspath(os.path.join("images", f"_live_frame_{stamp}.jpg")) try: - if cv2.imwrite(save_path, frame): - self._cleanup_old_live_frames(keep=30) - return 0, save_path, frame + if cv2.imwrite(save_path, frame_bgr): + self._cleanup_old_live_frames(keep=3000000) + return save_path except Exception as e: - print(f"保存实时帧失败: {e}") - return -1, None, None + print(f"保存订单帧失败: {e}") + return None def _cleanup_old_live_frames(self, keep=30): - """清理过期实时帧,防止 images/ 无限增长。""" + """清理过期订单帧,防止 images/ 无限增长。""" try: folder = os.path.abspath("images") files = [] @@ -1850,17 +1858,26 @@ class BaseDishApp(PendingLearningUIMixin): self._motion_still_since = None self._yolo_trigger_pending = False + def _motion_fingerprint(self, frame_bgr): + """只对菜品/餐盘区域提指纹,忽略旁边行人晃动。""" + return self._frame_fingerprint( + frame_bgr, + boxes=getattr(self, "_last_motion_boxes", None), + ) + def _update_motion_gate(self, frame_bgr): """根据当前帧更新运动状态;从动到静保持 hold 秒时置位 YOLO 触发。 + 运动只看餐盘区域(有上次检测框用框,否则画面中央),避免旁人晃动误触发。 + 状态: - moving — 画面在动(或刚开扫) - settling — 刚停住,累计静止时间 - wait_motion — 已识别过,静止中忽略,直到再出现运动 + moving — 菜品区域在动(或刚开扫) + settling — 菜品刚停住,累计静止时间 + wait_motion — 已识别过,菜品静止中忽略,直到菜品再出现运动 """ if frame_bgr is None: return False - fp = self._frame_fingerprint(frame_bgr) + fp = self._motion_fingerprint(frame_bgr) if fp is None: return False @@ -1876,7 +1893,7 @@ class BaseDishApp(PendingLearningUIMixin): self._motion_settle_fp = None self._motion_still_since = None if sim is not None: - print(f"[live] 检测到运动 sim={sim:.4f},等待静止后识别") + print(f"[live] 菜品区域运动 sim={sim:.4f},等待静止后识别") return False if state == "moving": @@ -1906,7 +1923,7 @@ class BaseDishApp(PendingLearningUIMixin): self._motion_settle_fp = None self._motion_still_since = None self._motion_prev_fp = fp - print(f"[live] 静止满 {hold:g}s,触发 YOLO 识别一次") + print(f"[live] 菜品静止满 {hold:g}s,触发 YOLO 识别一次") return True return False @@ -1918,6 +1935,7 @@ class BaseDishApp(PendingLearningUIMixin): self._last_reported_sig = None self._last_detect_fp = None self._last_live_dishes = None + self._last_motion_boxes = None self._reset_motion_gate(initial_state="moving") job = getattr(self, "_live_job", None) if job is not None: @@ -1988,6 +2006,7 @@ class BaseDishApp(PendingLearningUIMixin): self._live_stable_committed_sig = None self._last_detect_fp = None self._last_live_dishes = None + self._last_motion_boxes = None self._reset_motion_gate(initial_state="moving") if hasattr(self, "camera_btn"): try: @@ -2136,13 +2155,77 @@ class BaseDishApp(PendingLearningUIMixin): ) return Image.alpha_composite(rgba, overlay).convert("RGB") - def _frame_fingerprint(self, frame_bgr): - """小图灰度指纹,用于静止画面相似度判断(轻量、抗轻微噪声)。""" + def _normalize_motion_boxes(self, boxes, width, height): + """把菜品框规范成像素矩形列表;无效则空列表。""" + out = [] + for box in boxes or []: + try: + if len(box) < 4: + continue + x1, y1, x2, y2 = [int(round(float(v))) for v in box[:4]] + except Exception: + continue + x1 = max(0, min(width - 1, x1)) + x2 = max(0, min(width, x2)) + y1 = max(0, min(height - 1, y1)) + y2 = max(0, min(height, y2)) + if x2 - x1 < 8 or y2 - y1 < 8: + continue + out.append((x1, y1, x2, y2)) + return out + + def _motion_roi_boxes(self, frame_bgr, boxes=None): + """运动判定 ROI:优先上次/传入餐盘框(略扩张);否则用画面中央放盘区。""" + if frame_bgr is None: + return [] + h, w = frame_bgr.shape[:2] + rois = self._normalize_motion_boxes(boxes, w, h) + if not rois: + # 中央约 60% 区域,避开两侧行人 + mw, mh = int(w * 0.6), int(h * 0.6) + x1 = max(0, (w - mw) // 2) + y1 = max(0, (h - mh) // 2) + return [(x1, y1, x1 + mw, y1 + mh)] + pad_x = max(8, int(w * 0.03)) + pad_y = max(8, int(h * 0.03)) + expanded = [] + for x1, y1, x2, y2 in rois: + expanded.append(( + max(0, x1 - pad_x), + max(0, y1 - pad_y), + min(w, x2 + pad_x), + min(h, y2 + pad_y), + )) + return expanded + + def _frame_fingerprint(self, frame_bgr, boxes=None): + """小图灰度指纹,用于静止/运动判断(默认只看餐盘/中央 ROI,抗旁人晃动)。""" if frame_bgr is None: return None try: gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY) - small = cv2.resize(gray, (64, 64), interpolation=cv2.INTER_AREA) + h, w = gray.shape[:2] + rois = self._motion_roi_boxes(frame_bgr, boxes) + if len(rois) == 1: + x1, y1, x2, y2 = rois[0] + region = gray[y1:y2, x1:x2] + else: + # 多盘:拼成横向条带,避免整帧边缘干扰 + crops = [gray[y1:y2, x1:x2] for x1, y1, x2, y2 in rois] + target_h = 64 + resized = [] + for crop in crops: + if crop.size == 0: + continue + ch, cw = crop.shape[:2] + nw = max(8, int(round(cw * (target_h / float(ch))))) + resized.append(cv2.resize(crop, (nw, target_h), interpolation=cv2.INTER_AREA)) + if not resized: + return None + region = np.hstack(resized) + if region is None or region.size == 0: + return None + small = cv2.resize(region, (64, 64), interpolation=cv2.INTER_AREA) small = cv2.GaussianBlur(small, (3, 3), 0) v = small.astype(np.float32).ravel() v -= float(v.mean()) @@ -2271,9 +2354,9 @@ class BaseDishApp(PendingLearningUIMixin): self.root.after(0, _moving_tip) return - fp = self._frame_fingerprint(frame) - stem = Path(save_path).stem if save_path else f"_live_{int(time.time() * 1000)}" + stem = f"_live_frame_{int(time.time() * 1000)}" # 内存直传 YOLO;裁剪落盘供购物车缩略图 / 待学习;跳过中间 Plate 可视化 + # 整帧 images/ 仅在「有一单」时由 _save_order_image 写入 self.root.after(0, lambda: self._set_result_text("扫描中:YOLO 检测餐盘…", "busy")) visualize_save_path, crop_items, boxes = self.app.process_image( frame, @@ -2284,23 +2367,25 @@ class BaseDishApp(PendingLearningUIMixin): cropped_file_paths = [c.get("path") for c in crop_items] crop_arrays = [c.get("image") for c in crop_items] - # 识别完成后进入 wait_motion:持续静止不再识别,须再动→静 - self._last_detect_fp = fp + # 识别完成后进入 wait_motion:只盯餐盘区域,旁人晃动不触发 + self._last_motion_boxes = list(boxes or []) if crop_items else None + self._last_detect_fp = self._motion_fingerprint(frame) self._motion_state = "wait_motion" self._yolo_trigger_pending = False if not crop_items: self._last_live_dishes = [] + self._last_motion_boxes = None self._reset_live_stability() def _no_plate(): self._live_overlay_dishes = [] - self.render_result_dock([], None) - self._set_result_text("扫描中:未检测到餐盘", "warn") + # 未检出新菜:左侧已点清单保留上次结果,不清空 + self._set_result_text("扫描中:未检测到餐盘(已点清单保持上次)", "warn") if hasattr(self, "scan_tip_label"): try: self.scan_tip_label.config( - text="扫描中 · 未检出餐盘 · 静止中等待下一次运动" + text="扫描中 · 未检出餐盘 · 左侧保持上次已点 · 等待下一次运动" ) except Exception: pass @@ -2315,7 +2400,7 @@ class BaseDishApp(PendingLearningUIMixin): ), ) final_result, _, unknown_count, labeled_path, dishes = self.run_plate_predict_batch( - frame if frame is not None else save_path, + frame, cropped_file_paths, boxes, auto_pending=False, @@ -2323,16 +2408,26 @@ class BaseDishApp(PendingLearningUIMixin): det_confs=[c.get("conf") for c in crop_items], ) self._last_live_dishes = list(dishes or []) - preview = labeled_path or visualize_save_path or save_path + self._last_motion_boxes = [ + d.get("box") for d in (dishes or []) if d.get("box") + ] or list(boxes or []) + # 用识别后的餐盘框再锚一次,后续 wait_motion 更稳 + self._last_detect_fp = self._motion_fingerprint(frame) known_dishes = [d for d in dishes if not d.get("is_unknown")] - # 实时:仅有已识别菜才上报;相同结果只报一次;排餐比对不刷 pending - if known_dishes and save_path: - self.report_recognition( - save_path, known_dishes, preview, - schedule_check=False, dedupe=True, - ) + # 有一单(至少 1 个已识别菜,且与上次不同)才存 images/ 并上报;无单不落盘 + save_path = None + if known_dishes: + sig = self._report_signature(known_dishes) + if sig and sig != self._last_reported_sig: + save_path = self._save_order_image(frame) + if save_path: + self.report_recognition( + save_path, known_dishes, labeled_path or visualize_save_path or save_path, + schedule_check=False, dedupe=True, + ) else: self._last_reported_sig = None + preview = labeled_path or visualize_save_path or save_path # 同一结果稳定满 hold 秒 → 未知菜写入待学习(同场景一次;盘走不刷入) commit_payload = self._update_live_stability(dishes) @@ -2376,8 +2471,10 @@ class BaseDishApp(PendingLearningUIMixin): msg += f"|未知 {len(unknown)}(不入清单)" self._set_result_text(msg, "ok" if not unknown else "warn") else: + keep_hint = "|左侧保持上次已点" if self._cart else "" self._set_result_text( - txt or f"扫描中|检出 {len(ds)} 盘,尚无达到阈值的菜品", + (txt or f"扫描中|检出 {len(ds)} 盘,尚无达到阈值的菜品") + + keep_hint, "warn", ) @@ -2439,7 +2536,10 @@ class BaseDishApp(PendingLearningUIMixin): self._set_result_text("已清空本次已点菜品", "ok") def fill_cart_from_dishes(self, dishes): - """左侧清单只放已识别菜;未知仅留在预览标注 / 待学习流程。""" + """左侧清单只放已识别菜;未知仅留在预览标注 / 待学习流程。 + + 若本次没有新的已识别菜,则保留上次清单不刷新。 + """ cart = [] for d in dishes or []: if d.get("is_unknown"): @@ -2456,8 +2556,13 @@ class BaseDishApp(PendingLearningUIMixin): "pending_id": d.get("pending_id"), "suggested_category": d.get("suggested_category"), }) - self._cart = cart - self._last_dishes = dishes or [] + if cart: + self._cart = cart + self._last_dishes = dishes or [] + elif not self._cart: + # 本来就是空清单,同步记录本次结果 + self._last_dishes = dishes or [] + # else: 本次无新已识别菜 → 左侧继续展示上次已点菜品 def _change_qty(self, idx, delta): if idx < 0 or idx >= len(self._cart): diff --git a/ui/pending_learning_ui.py b/ui/pending_learning_ui.py index 4835d8d..db0db59 100644 --- a/ui/pending_learning_ui.py +++ b/ui/pending_learning_ui.py @@ -117,10 +117,113 @@ def resolve_in_today_menu(dish_name, match_scope=None): return False +def _period_menu_choices(): + """当前餐段菜名列表,供学习下拉。返回 (餐段名, [菜名...], 是否当前餐段)。 + + 有当前餐段 → 只返回该餐段菜品; + 不在任何餐段 → 回退今日全部菜品(label=今日菜单)。 + """ + try: + from dao.meal_schedule import get_meal_schedule_store + store = get_meal_schedule_store() + period = store.current_period() or {} + period_names = sorted(store.current_period_dish_names() or []) + if period_names: + pname = (period.get("mealPeriodName") or "").strip() or "当前餐段" + return pname, period_names, True + today_names = sorted(store.today_dish_names() or []) + return "今日菜单", today_names, False + except Exception: + return "当前餐段", [], False + + class PendingLearningUIMixin: """待学习库界面与操作。""" # ---------- 通用小工具(不依赖宿主实现) ---------- + def _pl_learn_name_choices(self, suggested=""): + """学习下拉候选:仅当前餐段菜品(不在餐段时用今日菜单)。""" + period_name, period_names, is_period = _period_menu_choices() + values = list(period_names) + suggested = (suggested or "").strip() + if suggested and suggested in values: + initial = suggested + else: + initial = "" + return period_name, period_names, values, initial, is_period + + def _build_today_menu_section(self, parent): + """待学习库顶部:展示今日菜单,高亮当前餐段。""" + try: + from dao.meal_schedule import get_meal_schedule_store + info = get_meal_schedule_store().periods_for_display() + except Exception as exc: + info = {"error": str(exc), "periods": [], "current_period": None} + + card = RoundCard(parent, title="今日菜单", + subtitle="学习时可从当前餐段菜品下拉选择", + radius=RADIUS["md"], padding=10) + card.pack(fill=tk.X, padx=14, pady=(8, 4)) + body = card.body + + periods = info.get("periods") or [] + cur_name = (info.get("current_period") or "").strip() + if info.get("error") and not periods: + tk.Label(body, text=f"排餐暂不可用:{info.get('error')}", + bg=COLORS["surface"], fg=COLORS["warn"], + font=FONTS["small"], wraplength=1000, justify=tk.LEFT + ).pack(anchor=tk.W) + return + if not periods: + tk.Label(body, text="今日暂无排餐数据,学习时仍可手输菜名。", + bg=COLORS["surface"], fg=COLORS["muted"], + font=FONTS["small"]).pack(anchor=tk.W) + return + + date_txt = info.get("date") or "" + head = tk.Frame(body, bg=COLORS["surface"]) + head.pack(fill=tk.X, pady=(0, 6)) + if date_txt: + Chip(head, text=date_txt, tone="mute", bg=COLORS["surface"]).pack(side=tk.LEFT, padx=(0, 6)) + if cur_name: + Chip(head, text=f"当前:{cur_name}", tone="ok", bg=COLORS["surface"]).pack(side=tk.LEFT) + + for p in periods: + is_cur = bool(p.get("is_current")) + row_bg = COLORS["surface_alt"] if is_cur else COLORS["surface"] + row = tk.Frame(body, bg=row_bg, + highlightbackground=COLORS["border"] if is_cur else COLORS["surface"], + highlightthickness=1 if is_cur else 0) + row.pack(fill=tk.X, pady=3) + pad = tk.Frame(row, bg=row_bg) + pad.pack(fill=tk.X, padx=8, pady=6) + + title = (p.get("name") or "餐段").strip() + st = (p.get("startTime") or "").strip() + et = (p.get("endTime") or "").strip() + time_txt = f"{st}-{et}" if st or et else "" + dishes = p.get("dishes") or [] + names = [d.get("name") for d in dishes if d.get("name")] + + title_row = tk.Frame(pad, bg=row_bg) + title_row.pack(fill=tk.X) + tk.Label(title_row, text=title, bg=row_bg, + fg=COLORS["accent"] if is_cur else COLORS["text"], + font=FONTS["ui_bold"]).pack(side=tk.LEFT) + if time_txt: + tk.Label(title_row, text=time_txt, bg=row_bg, fg=COLORS["muted"], + font=FONTS["small"]).pack(side=tk.LEFT, padx=8) + Chip(title_row, text=f"{len(names)} 道", + tone="ok" if is_cur else "mute", bg=row_bg).pack(side=tk.LEFT) + if is_cur: + Chip(title_row, text="当前餐段", tone="ok", bg=row_bg).pack(side=tk.LEFT, padx=6) + + menu_txt = "、".join(names) if names else "(本餐段暂无菜品)" + tk.Label(pad, text=menu_txt, bg=row_bg, + fg=COLORS["text_body"] if names else COLORS["muted"], + font=FONTS["small"], wraplength=1000, justify=tk.LEFT, + anchor="w").pack(fill=tk.X, pady=(4, 0)) + def _pl_toast(self, text, tone="info"): if hasattr(self, "_toast"): try: @@ -253,7 +356,7 @@ class PendingLearningUIMixin: pass self._pending_dialog_open = True - dialog = self._pl_dialog("待学习菜品库", 1120, 720) + dialog = self._pl_dialog("待学习菜品库", 1120, 820) def _close_pending(): self._pending_dialog_open = False @@ -289,6 +392,8 @@ class PendingLearningUIMixin: bg=COLORS["bg"], fg=COLORS["muted"], font=FONTS["small"], ).pack(anchor=tk.W, padx=18) + self._build_today_menu_section(dialog) + if items: search_row = tk.Frame(dialog, bg=COLORS["bg"]) search_row.pack(fill=tk.X, padx=16, pady=(10, 4)) @@ -444,11 +549,8 @@ class PendingLearningUIMixin: command=win.destroy).pack(pady=(8, 12)) def _learn_pending_from_dialog(self, pending_id, suggested, dialog): - """待学习库:用下拉框选择已有菜名后学习(也可直接手输新菜名)。""" - try: - existing_names = [name for name, _cnt, _thumb in self.manager.get_template_list()] - except Exception: - existing_names = [] + """待学习库:下拉仅选当前餐段菜品。""" + period_name, period_names, values, initial, is_period = self._pl_learn_name_choices(suggested) pick = tk.Toplevel(dialog) pick.title("选择菜品名称") @@ -459,23 +561,31 @@ class PendingLearningUIMixin: tk.Label(pick, text="学习到哪个菜名?", bg=COLORS["bg"], fg=COLORS["text"], font=FONTS["h2"]).pack(padx=20, pady=(18, 4), anchor=tk.W) - tk.Label(pick, text="可从已有菜名中选择,也可以直接输入一个新菜名。", - bg=COLORS["bg"], fg=COLORS["muted"], font=FONTS["small"], - ).pack(padx=20, anchor=tk.W) + if values and is_period: + tip = f"可从当前餐段「{period_name}」下拉选择,也可手动输入菜名。" + elif values: + tip = f"当前不在餐段时间,可从「{period_name}」下拉选择,也可手动输入。" + else: + tip = "暂无排餐菜品可选,请手动输入菜名。" + tk.Label(pick, text=tip, bg=COLORS["bg"], fg=COLORS["muted"], font=FONTS["small"], + wraplength=340, justify=tk.LEFT).pack(padx=20, anchor=tk.W) + + if values: + Chip(pick, text=f"{period_name} · {len(values)} 道", + tone="ok", bg=COLORS["bg"]).pack(padx=20, pady=(8, 0), anchor=tk.W) - initial = suggested if suggested and suggested in existing_names else "" name_var = tk.StringVar(value=initial) combo = ttk.Combobox( - pick, textvariable=name_var, values=existing_names, + pick, textvariable=name_var, values=values, width=34, style="Large.TEntry", ) combo.pack(padx=20, pady=10, fill=tk.X) combo.focus_set() - if not existing_names: - tk.Label(pick, text="当前模板库还没有菜名,输入的新名字会新建一个类别。", + if not values: + tk.Label(pick, text="暂无可选菜名,请直接输入新菜名(将新建类别)。", bg=COLORS["bg"], fg=COLORS["warn"], font=FONTS["caption"], - wraplength=280, justify=tk.LEFT).pack(padx=20, anchor=tk.W) + wraplength=300, justify=tk.LEFT).pack(padx=20, anchor=tk.W) btns = tk.Frame(pick, bg=COLORS["bg"]) btns.pack(fill=tk.X, padx=20, pady=(10, 18)) @@ -483,7 +593,7 @@ class PendingLearningUIMixin: def _confirm(): category = (name_var.get() or "").strip() if not category: - messagebox.showwarning("提示", "请先输入或选择菜品名称", parent=pick) + messagebox.showwarning("提示", "请先选择或输入菜品名称", parent=pick) return ok = self.manager.learn_from_pending(pending_id, category) if ok: @@ -544,12 +654,9 @@ class PendingLearningUIMixin: on_finished(False) return - try: - existing_names = [name for name, _cnt, _thumb in self.manager.get_template_list()] - except Exception: - existing_names = [] + period_name, period_names, learn_values, _, is_period = self._pl_learn_name_choices("") - dialog = self._pl_dialog("新菜确认学习", 860, 620) + dialog = self._pl_dialog("新菜确认学习", 860, 680) learned_count = {"n": 0} thumb_refs = [] @@ -561,12 +668,22 @@ class PendingLearningUIMixin: font=FONTS["h1"]).pack(side=tk.LEFT) Chip(head, text=f"{len(unknowns)} 个待确认", tone="warn", bg=COLORS["bg"] ).pack(side=tk.LEFT, padx=10) + if learn_values: + Chip(head, text=f"{period_name} {len(learn_values)}道", tone="ok", + bg=COLORS["bg"]).pack(side=tk.LEFT) + if learn_values and is_period: + tip = f"可从当前餐段「{period_name}」下拉选择,也可手输菜名;直接关闭则保留在待学习库。" + elif learn_values: + tip = f"可从「{period_name}」下拉选择,也可手输菜名;直接关闭则保留在待学习库。" + else: + tip = "暂无排餐菜品,可手输菜名学习;直接关闭则保留在待学习库。" tk.Label( dialog, - text="识别置信度低于阈值。确认菜名后可立即学习;直接关闭则保留在待学习库,稍后处理。", + text=tip, bg=COLORS["bg"], fg=COLORS["muted"], font=FONTS["small"], wraplength=780, justify=tk.LEFT, ).pack(anchor=tk.W, padx=18) + self._build_today_menu_section(dialog) card = RoundCard(dialog, radius=RADIUS["lg"], padding=10, expand_body=True) card.pack(fill=tk.BOTH, expand=True, padx=14, pady=10) @@ -650,9 +767,10 @@ class PendingLearningUIMixin: name_row.pack(fill=tk.X, pady=(6, 4)) tk.Label(name_row, text="菜名", bg=COLORS["surface_alt"], fg=COLORS["text_body"], font=FONTS["ui"]).pack(side=tk.LEFT) - name_var = tk.StringVar(value=suggested) + row_initial = suggested if suggested in learn_values else (suggested or "") + name_var = tk.StringVar(value=row_initial) ttk.Combobox( - name_row, textvariable=name_var, values=existing_names, width=26, + name_row, textvariable=name_var, values=learn_values, width=26, ).pack(side=tk.LEFT, padx=8) status_label = tk.Label(mid, text="待确认", bg=COLORS["surface_alt"],