""" 企业微信 RPA 控制台 依赖:pip install mss Pillow numpy pyautogui pyperclip pywin32 运行:python wechat_gui.py """ import tkinter as tk from tkinter import scrolledtext, ttk import threading import queue import time import sys import os import json sys.path.insert(0, os.path.dirname(__file__)) from wechat_bot import WeChatBot, AUTO_REPLY_TEXT, POLL_INTERVAL, MOUSE_IDLE_SECONDS, MOUSE_IDLE_ENABLED # ─────────────────── 设计系统:Linear × Cursor 产品化暗色 ─────────────────── # 参考:Linear / Cursor / ChatGPT — 弱边框、强字阶、单一强调色、零军工装饰 BG = "#0B0B0F" SURFACE = "#121216" SURFACE2 = "#18181D" ELEVATED = "#1C1C22" INK = "#EDEDF0" MUTED = "#8B8B96" FAINT = "#5C5C66" LINE = "#26262D" LINE_SOFT = "#1F1F26" ACCENT = "#6E7BF2" # Linear indigo · 唯一主色 ACCENT_HOV = "#838DF5" ACCENT_DIM = "#22264A" ACCENT_SOFT= "#16182A" GREEN = "#3ECF8E" GREEN_DIM = "#1A3A2C" AMBER = "#E8A838" ROSE = "#E5484D" ROSE_SOFT = "#2A1518" # 兼容旧引用名 TEAL = ACCENT TEAL_HOV = ACCENT_HOV TEAL_SOFT = ACCENT_SOFT CYAN = ACCENT CYAN_DIM = ACCENT_DIM MAGENTA = ACCENT TERMINAL = GREEN PHOSPHOR = INK GLASS = ELEVATED VOID = BG LINE_HI = LINE VIOLET = ACCENT FONT = "Microsoft YaHei UI" FONT_MONO = "Consolas" WIN_TITLE = "甄养堂 · 企微客服助手" FS_TITLE = 17 FS_H1 = 14 FS_BODY = 12 FS_LABEL = 11 FS_META = 11 FS_BTN = 12 FS_STAT = 22 FS_LOG = 12 TAB_NAMES = ("运行", "智能", "业务") # ────────────────────────────────────────────────── class LogQueue: """将 print 重定向到队列""" def __init__(self, q): self.q = q def write(self, msg): if msg.strip(): self.q.put(("log", msg.rstrip())) def flush(self): pass class BotThread(threading.Thread): def __init__(self, q, reply_text, poll_sec, mouse_idle_enabled=True, mouse_idle_sec=20): super().__init__(daemon=True) self.q = q self.reply_text = reply_text self.poll_sec = poll_sec self.mouse_idle_enabled = mouse_idle_enabled self.mouse_idle_sec = mouse_idle_sec self._stop = threading.Event() def run(self): import wechat_bot as wb import win32gui wb.AUTO_REPLY_TEXT = self.reply_text bot = WeChatBot() bot.mouse_idle_enabled = self.mouse_idle_enabled bot.mouse_idle_seconds = self.mouse_idle_sec bot._stop_check = self._stop gui_hwnd = win32gui.FindWindow(None, WIN_TITLE) bot.gui_hwnd = gui_hwnd if not bot.connect(): self.q.put(("status", "error")) return bot.set_topmost(True) print("[+] 企业微信已设为置顶窗口,监听期间保持最前显示。") if bot.mouse_idle_enabled: print(f"[+] 人机共存已开启:鼠标静止 {bot.mouse_idle_seconds:.0f}s 后才自动操作。") self.q.put(("status", "running")) self.q.put(("info", { "hwnd": f"0x{bot.hwnd:08X}", "size": f"{bot.R - bot.L}×{bot.B - bot.T}", "input": f"({bot.input_x}, {bot.input_y})", })) while not self._stop.is_set(): bot._poll_once() self.q.put(("stats", { "replied": len(bot.replied), "false_pos": len(bot.false_pos_rows), })) self._stop.wait(self.poll_sec) bot.set_topmost(False) print("[+] 企业微信置顶已取消。") self.q.put(("status", "stopped")) def stop(self): self._stop.set() # ────────────────────────────────────────────────── class Toggle(tk.Frame): """胶囊开关。""" def __init__(self, parent, variable: tk.BooleanVar, text="", command=None, **kw): super().__init__(parent, bg=kw.get("bg", SURFACE)) self.var = variable self.command = command self._bg = kw.get("bg", SURFACE) self.canvas = tk.Canvas(self, width=42, height=24, bg=self._bg, highlightthickness=0, cursor="hand2") self.canvas.pack(side="left") self.canvas.bind("", self._toggle) self.label = tk.Label(self, text=text, bg=self._bg, fg=INK, font=(FONT, FS_BODY), cursor="hand2", justify="left") self.label.pack(side="left", padx=(10, 0)) self.label.bind("", self._toggle) self.var.trace_add("write", lambda *_: self._draw()) self._draw() def _toggle(self, _=None): self.var.set(not self.var.get()) if self.command: self.command() def _draw(self): c = self.canvas c.delete("all") on = self.var.get() track = ACCENT if on else "#2E2E36" c.create_oval(0, 0, 24, 24, fill=track, outline="") c.create_oval(18, 0, 42, 24, fill=track, outline="") c.create_rectangle(12, 0, 30, 24, fill=track, outline="") kx = 20 if on else 2 c.create_oval(kx, 3, kx + 18, 21, fill="#FFFFFF", outline="") class SoftButton(tk.Label): """扁平按钮(不用 Canvas,避免毛边叠字)。""" def __init__(self, parent, text, command=None, *, width=None, height=None, primary=False, danger=False, **kw): self._command = command self._primary = primary self._danger = danger self._enabled = True self._hover = False super().__init__( parent, text=text, font=(FONT, FS_BTN, "bold" if primary else "normal"), padx=16, pady=8, cursor="hand2", ) self._apply() self.bind("", self._on_enter) self.bind("", self._on_leave) self.bind("", self._on_click) def _colors(self): if not self._enabled: return SURFACE2, FAINT if self._danger: return (ROSE if self._hover else ROSE_SOFT, "#FFFFFF" if self._hover else ROSE) if self._primary: return (ACCENT_HOV if self._hover else ACCENT), "#FFFFFF" return (ELEVATED if self._hover else SURFACE2, INK if self._hover else MUTED) def _apply(self): bg, fg = self._colors() self.configure(bg=bg, fg=fg) def _on_enter(self, _=None): self._hover = True self._apply() def _on_leave(self, _=None): self._hover = False self._apply() def _on_click(self, _=None): if self._enabled and self._command: self._command() def set_enabled(self, enabled: bool): self._enabled = enabled self.configure(cursor="hand2" if enabled else "arrow") self._apply() def set_text(self, text: str): self.configure(text=text) NeonButton = SoftButton class SoftCard(tk.Frame): """干净卡片:只放标题,说明放在内容区,禁止 wraplength 叠字。""" def __init__(self, parent, title=None, subtitle=None, pad=20, **kw): super().__init__(parent, bg=kw.get("bg", BG)) shell = tk.Frame(self, bg=SURFACE, highlightthickness=1, highlightbackground=LINE) shell.pack(fill="both", expand=True) if title: head = tk.Frame(shell, bg=SURFACE) head.pack(fill="x", padx=pad, pady=(16, 0)) tk.Label(head, text=title, bg=SURFACE, fg=INK, font=(FONT, FS_H1)).pack(anchor="w") self.body = tk.Frame(shell, bg=SURFACE) self.body.pack(fill="both", expand=True, padx=pad, pady=(12, 18)) HullPanel = SoftCard class SoftTabs(tk.Frame): """分段 Tab。""" def __init__(self, parent, names, on_change, **kw): super().__init__(parent, bg=kw.get("bg", BG)) self._on_change = on_change self._btns = {} self._bars = {} self._ready = False bar = tk.Frame(self, bg=BG) bar.pack(fill="x") for i, name in enumerate(names): cell = tk.Frame(bar, bg=BG, cursor="hand2") cell.pack(side="left", padx=(0 if i == 0 else 2, 0)) lbl = tk.Label(cell, text=name, bg=BG, fg=MUTED, font=(FONT, FS_BODY), padx=12, pady=8, cursor="hand2") lbl.pack() underline = tk.Frame(cell, bg=BG, height=2) underline.pack(fill="x") for w in (cell, lbl, underline): w.bind("", lambda e, n=name: self.select(n)) self._btns[name] = lbl self._bars[name] = underline tk.Frame(self, bg=LINE, height=1).pack(fill="x") self._ready = True self.select(names[0]) def select(self, name): for n, lbl in self._btns.items(): on = n == name lbl.configure(fg=INK if on else MUTED) self._bars[n].configure(bg=ACCENT if on else BG) if self._ready: self._on_change(name) TabStrip = SoftTabs class App(tk.Tk): def __init__(self): super().__init__() self.title(WIN_TITLE) self.configure(bg=BG) self.geometry("1280x860") self.minsize(1080, 720) self._q = queue.Queue() self._thread = None self._start_time = None self._running = False self._fx_phase = 0 self._status_key = "stopped" self._build() self._tick() self._fx_tick() def _build(self): self._build_header() self._build_metrics_bar() body = tk.Frame(self, bg=BG) body.pack(fill="both", expand=True, padx=24, pady=(8, 20)) left = tk.Frame(body, bg=BG, width=400) left.pack(side="left", fill="y", padx=(0, 16)) left.pack_propagate(False) self._tab_pages = {} page_host = tk.Frame(left, bg=BG) self._tab_strip = SoftTabs(left, TAB_NAMES, self._show_tab, bg=BG) self._tab_strip.pack(fill="x", pady=(0, 12)) page_host.pack(fill="both", expand=True) for name in TAB_NAMES: self._tab_pages[name] = tk.Frame(page_host, bg=BG) self._build_system_tab(self._tab_pages["运行"]) self._build_ai_tab(self._tab_pages["智能"]) self._build_ops_tab(self._tab_pages["业务"]) self._show_tab("运行") right = tk.Frame(body, bg=BG) right.pack(side="left", fill="both", expand=True) self._build_log(right) self._refresh_registration_count() self._refresh_sessions_count() def _show_tab(self, name): for n, page in self._tab_pages.items(): if n == name: page.pack(fill="both", expand=True) else: page.pack_forget() def _fx_tick(self): self._fx_phase = (self._fx_phase + 1) % 100000 try: if hasattr(self, "_sdot") and self._status_key == "running": t = (self._fx_phase % 50) / 50.0 col = GREEN if t < 0.55 else GREEN_DIM self._sdot.itemconfig(self._sdot_id, fill=col) except Exception: pass self.after(80, self._fx_tick) def _field_label(self, parent, text): tk.Label(parent, text=text, bg=SURFACE, fg=MUTED, font=(FONT, FS_META)).pack(anchor="w") def _padded_entry(self, parent, textvariable, width=None): box = tk.Frame(parent, bg=SURFACE2, highlightthickness=1, highlightbackground=LINE) kw = dict( textvariable=textvariable, bg=SURFACE2, fg=INK, insertbackground=ACCENT, relief="flat", font=(FONT, FS_BODY), bd=0, highlightthickness=0, ) if width is not None: kw["width"] = width ent = tk.Entry(box, **kw) ent.pack(fill="x", padx=12, pady=9) def _focus_in(_=None): box.configure(highlightbackground=ACCENT) def _focus_out(_=None): box.configure(highlightbackground=LINE) ent.bind("", _focus_in) ent.bind("", _focus_out) return box, ent def _build_header(self): hdr = tk.Frame(self, bg=SURFACE) hdr.pack(fill="x") tk.Frame(self, bg=LINE, height=1).pack(fill="x") inner = tk.Frame(hdr, bg=SURFACE) inner.pack(fill="x", padx=24, pady=14) brand = tk.Frame(inner, bg=SURFACE) brand.pack(side="left") mark = tk.Label(brand, text="甄", bg=ACCENT, fg="#FFFFFF", font=(FONT, 12, "bold"), width=3, pady=6) mark.pack(side="left") titles = tk.Frame(brand, bg=SURFACE) titles.pack(side="left", padx=(12, 0)) tk.Label(titles, text="企微客服助手", bg=SURFACE, fg=INK, font=(FONT, FS_TITLE)).pack(anchor="w") tk.Label(titles, text="甄养堂互联网医院", bg=SURFACE, fg=MUTED, font=(FONT, FS_META)).pack(anchor="w") status = tk.Frame(inner, bg=SURFACE2, highlightthickness=1, highlightbackground=LINE) status.pack(side="left", padx=(24, 0), ipadx=10, ipady=5) sr = tk.Frame(status, bg=SURFACE2) sr.pack() self._sdot = tk.Canvas(sr, width=8, height=8, bg=SURFACE2, highlightthickness=0) self._sdot.pack(side="left", pady=3) self._sdot_id = self._sdot.create_oval(0, 0, 8, 8, fill=FAINT, outline="") self._s_lbl = tk.Label(sr, text="未连接", bg=SURFACE2, fg=MUTED, font=(FONT, FS_META)) self._s_lbl.pack(side="left", padx=(8, 0)) meta = tk.Frame(inner, bg=SURFACE) meta.pack(side="left", padx=(18, 0)) for i, (label, attr) in enumerate([ ("窗口", "_hwnd_lbl"), ("尺寸", "_size_lbl"), ("输入", "_ibox_lbl"), ]): cell = tk.Frame(meta, bg=SURFACE) cell.pack(side="left", padx=(0 if i == 0 else 14, 0)) tk.Label(cell, text=label, bg=SURFACE, fg=FAINT, font=(FONT, 10)).pack(anchor="w") lbl = tk.Label(cell, text="—", bg=SURFACE, fg=MUTED, font=(FONT_MONO, 10)) lbl.pack(anchor="w") setattr(self, attr, lbl) btns = tk.Frame(inner, bg=SURFACE) btns.pack(side="right") self._stop_btn = SoftButton( btns, "停止", command=self._on_stop, danger=True) self._stop_btn.pack(side="right", padx=(8, 0)) self._stop_btn.set_enabled(False) self._start_btn = SoftButton( btns, "开始监听", command=self._on_start, primary=True) self._start_btn.pack(side="right") def _build_metrics_bar(self): bar = tk.Frame(self, bg=BG) bar.pack(fill="x", padx=24, pady=(14, 0)) for i, (label, attr) in enumerate([ ("已回复", "_lbl_replied"), ("假阳性", "_lbl_fp"), ("待挂号", "_reg_count_lbl"), ("会话档案", "_sess_count_lbl"), ]): cell = tk.Frame(bar, bg=SURFACE, highlightthickness=1, highlightbackground=LINE) cell.pack(side="left", fill="x", expand=True, padx=(0 if i == 0 else 8, 0)) pad = tk.Frame(cell, bg=SURFACE) pad.pack(fill="x", padx=14, pady=10) tk.Label(pad, text=label, bg=SURFACE, fg=MUTED, font=(FONT, FS_META)).pack(anchor="w") v = tk.Label(pad, text="0", bg=SURFACE, fg=INK, font=(FONT, 20)) v.pack(anchor="w") setattr(self, attr, v) self._timer_lbl = tk.Label(self, text="运行时长 —", bg=BG, fg=FAINT, font=(FONT, FS_META)) self._timer_lbl.pack(anchor="w", padx=24, pady=(8, 0)) def _card(self, parent, title=None, pad=16): panel = SoftCard(parent, title=title, pad=pad, bg=BG) panel.pack(fill="both", expand=True, pady=(0, 10)) return panel, panel.body def _build_system_tab(self, p): panel = SoftCard(p, title="运行参数", bg=BG) panel.pack(fill="both", expand=True) inner = panel.body self._field_label(inner, "固定回复(AI 关闭时使用)") self._reply_var = tk.StringVar(value=AUTO_REPLY_TEXT) box, _ = self._padded_entry(inner, self._reply_var) box.pack(fill="x", pady=(6, 14)) row = tk.Frame(inner, bg=SURFACE) row.pack(fill="x", pady=(0, 14)) left = tk.Frame(row, bg=SURFACE) left.pack(side="left", fill="x", expand=True, padx=(0, 10)) self._field_label(left, "轮询间隔(秒)") self._poll_var = tk.StringVar(value=str(POLL_INTERVAL)) box, _ = self._padded_entry(left, self._poll_var, width=8) box.pack(fill="x", pady=(6, 0)) right = tk.Frame(row, bg=SURFACE) right.pack(side="left", fill="x", expand=True) self._field_label(right, "静止等待(秒)") self._idle_sec_var = tk.StringVar(value=str(MOUSE_IDLE_SECONDS)) box, _ = self._padded_entry(right, self._idle_sec_var, width=8) box.pack(fill="x", pady=(6, 0)) tk.Frame(inner, bg=LINE, height=1).pack(fill="x", pady=(4, 12)) self._mouse_idle_var = tk.BooleanVar(value=MOUSE_IDLE_ENABLED) Toggle(inner, self._mouse_idle_var, "鼠标静止后再自动操作").pack(anchor="w", fill="x") tk.Label(inner, text="有人动鼠标时暂停,静止满设定秒数后继续", bg=SURFACE, fg=FAINT, font=(FONT, 10)).pack(anchor="w", pady=(6, 0)) def _build_ai_tab(self, p): panel = SoftCard(p, title="智能回复", bg=BG) panel.pack(fill="both", expand=True) inner = panel.body self._ai_var = tk.BooleanVar(value=False) self._ctx_var = tk.BooleanVar(value=True) self._insult_var = tk.BooleanVar(value=False) self._mcp_var = tk.BooleanVar(value=False) try: from ai_config import ( AI_ENABLED, AI_CONTEXT_ENABLED, AI_COUNTER_INSULT_ENABLED, AI_MCP_ENABLED, ) self._ai_var.set(AI_ENABLED) self._ctx_var.set(AI_CONTEXT_ENABLED) self._insult_var.set(AI_COUNTER_INSULT_ENABLED) self._mcp_var.set(AI_MCP_ENABLED) except ImportError: pass for text, var in ( ("启用 AI 回复", self._ai_var), ("携带会话上下文", self._ctx_var), ("对骂模式(被骂时怼回)", self._insult_var), ("MCP 工具增强回复", self._mcp_var), ): Toggle(inner, var, text, command=self._refresh_ai_status).pack( anchor="w", pady=(0, 10), fill="x") tk.Frame(inner, bg=LINE, height=1).pack(fill="x", pady=(4, 12)) btn_row = tk.Frame(inner, bg=SURFACE) btn_row.pack(anchor="w", fill="x") SoftButton(btn_row, "高级配置", command=self._open_ai_config).pack(side="left") SoftButton(btn_row, "MCP 服务器", command=self._open_mcp_config).pack( side="left", padx=(8, 0)) self._ai_status = tk.Label(inner, text="", bg=SURFACE, fg=MUTED, font=(FONT, FS_META), anchor="w", justify="left") self._ai_status.pack(anchor="w", fill="x", pady=(12, 0)) self._refresh_ai_status() def _build_ops_tab(self, p): reg = SoftCard(p, title="挂号问诊登记", bg=BG) reg.pack(fill="x", pady=(0, 10)) row = tk.Frame(reg.body, bg=SURFACE) row.pack(fill="x") left = tk.Frame(row, bg=SURFACE) left.pack(side="left", fill="x", expand=True) self._reg_mirror = tk.Label(left, text="0", bg=SURFACE, fg=INK, font=(FONT, 26)) self._reg_mirror.pack(anchor="w") tk.Label(left, text="待处理线索", bg=SURFACE, fg=MUTED, font=(FONT, FS_META)).pack(anchor="w") SoftButton(row, "打开登记", command=self._open_registration_leads, primary=True).pack(side="right") sess = SoftCard(p, title="会话档案", bg=BG) sess.pack(fill="both", expand=True) row2 = tk.Frame(sess.body, bg=SURFACE) row2.pack(fill="x") left2 = tk.Frame(row2, bg=SURFACE) left2.pack(side="left", fill="x", expand=True) self._sess_mirror = tk.Label(left2, text="0", bg=SURFACE, fg=INK, font=(FONT, 26)) self._sess_mirror.pack(anchor="w") tk.Label(left2, text="上下文档案数", bg=SURFACE, fg=MUTED, font=(FONT, FS_META)).pack(anchor="w") SoftButton(row2, "管理档案", command=self._open_sessions).pack(side="right") def _build_log(self, p): card = SoftCard(p, title="运行日志", bg=BG) card.pack(fill="both", expand=True) inner = card.body tool = tk.Frame(inner, bg=SURFACE) tool.pack(fill="x", pady=(0, 10)) tk.Label(tool, text="实时输出机器人状态与回复过程", bg=SURFACE, fg=FAINT, font=(FONT, 10)).pack(side="left") SoftButton(tool, "清空", command=self._clear_log).pack(side="right") log_shell = tk.Frame(inner, bg="#0E0E12", highlightthickness=1, highlightbackground=LINE) log_shell.pack(fill="both", expand=True) self._log = scrolledtext.ScrolledText( log_shell, bg="#0E0E12", fg="#C8C8D0", font=(FONT_MONO, FS_LOG), relief="flat", bd=0, insertbackground=ACCENT, state="disabled", wrap="word", padx=14, pady=12) self._log.pack(fill="both", expand=True) self._log.tag_config("ok", foreground=GREEN) self._log.tag_config("warn", foreground=AMBER) self._log.tag_config("err", foreground=ROSE) self._log.tag_config("notify", foreground=ACCENT_HOV) self._log.tag_config("dim", foreground=FAINT) def _refresh_sessions_count(self): try: from conversation_store import ConversationStore path = os.path.join(os.path.dirname(__file__), "conversations.json") n = ConversationStore(path).count() if hasattr(self, "_sess_count_lbl"): self._sess_count_lbl.config(text=str(n), fg=INK if n else MUTED) if hasattr(self, "_sess_mirror"): self._sess_mirror.config(text=str(n), fg=INK if n else MUTED) except Exception: if hasattr(self, "_sess_count_lbl"): self._sess_count_lbl.config(text="—", fg=MUTED) if hasattr(self, "_sess_mirror"): self._sess_mirror.config(text="—", fg=MUTED) def _refresh_registration_count(self): try: from registration_store import RegistrationStore n = RegistrationStore().pending_count() if hasattr(self, "_reg_count_lbl"): self._reg_count_lbl.config(text=str(n), fg=INK if n else MUTED) if hasattr(self, "_reg_mirror"): self._reg_mirror.config(text=str(n), fg=INK if n else MUTED) except Exception: if hasattr(self, "_reg_count_lbl"): self._reg_count_lbl.config(text="—", fg=MUTED) if hasattr(self, "_reg_mirror"): self._reg_mirror.config(text="—", fg=MUTED) def _style_tree(self, win): style = ttk.Style(win) try: style.theme_use("clam") except Exception: pass style.configure( "Mgr.Treeview", background=SURFACE2, fieldbackground=SURFACE2, foreground=INK, rowheight=40, font=(FONT, 12), borderwidth=0, relief="flat", ) style.configure( "Mgr.Treeview.Heading", background=SURFACE, foreground=MUTED, font=(FONT, 11), relief="flat", padding=(12, 10), ) style.map( "Mgr.Treeview", background=[("selected", ACCENT_DIM)], foreground=[("selected", INK)], ) return style def _mgr_action_btn(self, parent, text, command, *, primary=False, danger=False, muted=False): return SoftButton( parent, text, command=command, primary=primary, danger=danger, ) def _open_mgr_shell(self, title, subtitle, geometry="1180x760", minsize=(1000, 640)): win = tk.Toplevel(self) win.title(title) win.configure(bg=BG) win.geometry(geometry) win.minsize(*minsize) win.transient(self) self._style_tree(win) shell = tk.Frame(win, bg=BG) shell.pack(fill="both", expand=True, padx=20, pady=20) card = tk.Frame(shell, bg=SURFACE, highlightthickness=1, highlightbackground=LINE) card.pack(fill="both", expand=True) footer = tk.Frame(card, bg=SURFACE) footer.pack(side="bottom", fill="x", padx=24, pady=(12, 20)) tk.Frame(footer, bg=LINE, height=1).pack(fill="x", pady=(0, 14)) foot_inner = tk.Frame(footer, bg=SURFACE) foot_inner.pack(fill="x") hdr = tk.Frame(card, bg=SURFACE) hdr.pack(side="top", fill="x", padx=28, pady=(24, 8)) tk.Label(hdr, text=title, bg=SURFACE, fg=INK, font=(FONT, 18)).pack(anchor="w") tk.Label( hdr, text=subtitle, bg=SURFACE, fg=MUTED, font=(FONT, 12), justify="left", wraplength=980, ).pack(anchor="w", pady=(6, 0)) body = tk.Frame(card, bg=SURFACE) body.pack(side="top", fill="both", expand=True, padx=24, pady=(8, 0)) return win, body, foot_inner def _conversations_store(self): from conversation_store import ConversationStore return ConversationStore(os.path.join(os.path.dirname(__file__), "conversations.json")) def _open_sessions(self): import time as _time from tkinter import messagebox win, body, foot = self._open_mgr_shell( "会话档案", "支持单选 / Ctrl·Shift 多选删除。删除后该客户下次再聊会重新建档。", ) status_lbl = tk.Label(foot, text="", bg=SURFACE, fg=MUTED, font=(FONT, 11)) status_lbl.pack(side="left") btn_row = tk.Frame(foot, bg=SURFACE) btn_row.pack(side="right") # 左右分栏:列表 + 预览 panes = tk.PanedWindow(body, orient="horizontal", bg=SURFACE, sashwidth=6, sashrelief="flat", bd=0, opaqueresize=True) panes.pack(fill="both", expand=True) left = tk.Frame(panes, bg=SURFACE) right = tk.Frame(panes, bg=SURFACE) panes.add(left, minsize=520, stretch="always") panes.add(right, minsize=360, stretch="always") list_card = tk.Frame(left, bg=SURFACE, highlightthickness=1, highlightbackground=LINE) list_card.pack(fill="both", expand=True, padx=(0, 8)) tk.Label(list_card, text="全部会话", bg=SURFACE, fg=MUTED, font=(FONT, 11, "bold")).pack(anchor="w", padx=16, pady=(14, 6)) tree_wrap = tk.Frame(list_card, bg=SURFACE) tree_wrap.pack(fill="both", expand=True, padx=8, pady=(0, 8)) cols = ("time", "msgs", "preview") tree = ttk.Treeview( tree_wrap, columns=cols, show="headings", selectmode="extended", style="Mgr.Treeview", ) tree.heading("time", text="最近更新") tree.heading("msgs", text="消息") tree.heading("preview", text="内容预览") tree.column("time", width=120, minwidth=110, stretch=False, anchor="w") tree.column("msgs", width=64, minwidth=56, stretch=False, anchor="center") tree.column("preview", width=420, minwidth=220, stretch=True, anchor="w") vsb = ttk.Scrollbar(tree_wrap, orient="vertical", command=tree.yview) tree.configure(yscrollcommand=vsb.set) tree.grid(row=0, column=0, sticky="nsew") vsb.grid(row=0, column=1, sticky="ns") tree_wrap.rowconfigure(0, weight=1) tree_wrap.columnconfigure(0, weight=1) prev_card = tk.Frame(right, bg=SURFACE, highlightthickness=1, highlightbackground=LINE) prev_card.pack(fill="both", expand=True, padx=(8, 0)) prev_hdr = tk.Frame(prev_card, bg=SURFACE) prev_hdr.pack(fill="x", padx=16, pady=(14, 6)) tk.Label(prev_hdr, text="对话预览", bg=SURFACE, fg=MUTED, font=(FONT, 11, "bold")).pack(side="left") sid_lbl = tk.Label(prev_hdr, text="", bg=SURFACE, fg=MUTED, font=(FONT_MONO, 10)) sid_lbl.pack(side="right") detail = scrolledtext.ScrolledText( prev_card, bg="#0E0E12", fg=INK, font=(FONT, 12), relief="flat", bd=0, wrap="word", padx=16, pady=14, spacing1=4, spacing3=8, insertbackground=ACCENT, ) detail.pack(fill="both", expand=True, padx=8, pady=(0, 8)) detail.tag_config("user", foreground=ACCENT_HOV, font=(FONT, 12)) detail.tag_config("agent", foreground=MUTED, font=(FONT, 12)) detail.tag_config("body", foreground=INK, font=(FONT, 12), lmargin1=12, lmargin2=12) detail.tag_config("empty", foreground=MUTED, font=(FONT, 12)) detail.configure(state="disabled") store = self._conversations_store() def _fmt_ts(ts): try: return _time.strftime("%m-%d %H:%M", _time.localtime(float(ts))) except Exception: return "—" def _set_status(text): status_lbl.config(text=text) def _clear_detail(): detail.configure(state="normal") detail.delete("1.0", "end") detail.configure(state="disabled") sid_lbl.config(text="") def _reload(): nonlocal store store = self._conversations_store() tree.delete(*tree.get_children()) for item in store.list_sessions(): tree.insert( "", "end", iid=item["session_id"], values=( _fmt_ts(item.get("updated")), item.get("message_count", 0), (item.get("preview") or "—")[:80], ), ) _clear_detail() _set_status(f"共 {store.count()} 条 · Ctrl / Shift 多选") self._refresh_sessions_count() def _on_select(_=None): sel = tree.selection() detail.configure(state="normal") detail.delete("1.0", "end") if len(sel) == 1: sid = sel[0] sid_lbl.config(text=sid) hist = store.history(sid)[-20:] if not hist: detail.insert("end", "这条会话还没有文字记录。", "empty") else: for m in hist: is_user = m.get("role") == "user" tag = "user" if is_user else "agent" label = "客户" if is_user else "客服" c = (m.get("content") or "").strip() or "—" detail.insert("end", f"{label}\n", tag) detail.insert("end", f"{c}\n\n", "body") _set_status(f"已选 1 条") elif len(sel) > 1: sid_lbl.config(text="") detail.insert("end", f"已选中 {len(sel)} 条会话。\n可批量删除,或点其中一条查看详情。", "empty") _set_status(f"已选 {len(sel)} 条") else: sid_lbl.config(text="") _set_status(f"共 {store.count()} 条 · Ctrl / Shift 多选") detail.configure(state="disabled") def _select_all(): kids = tree.get_children() if kids: tree.selection_set(kids) _on_select() def _delete_one(): sel = list(tree.selection()) if len(sel) != 1: messagebox.showinfo("提示", "请先选中 1 条要删除的会话", parent=win) return sid = sel[0] if not messagebox.askyesno("确认删除", "确定删除这条会话档案?", parent=win): return if self._conversations_store().delete(sid): self._append("✓ 已删除 1 条会话档案", "ok") _reload() def _delete_selected(): sel = list(tree.selection()) if not sel: messagebox.showinfo("提示", "请先选中要删除的会话(可多选)", parent=win) return if not messagebox.askyesno( "批量删除", f"确定删除选中的 {len(sel)} 条会话档案吗?不可恢复。", parent=win, ): return st = self._conversations_store() ok = sum(1 for sid in sel if st.delete(sid)) self._append(f"✓ 已批量删除 {ok} 条会话档案", "ok") _reload() def _clear_all(): st = self._conversations_store() n = st.count() if n <= 0: messagebox.showinfo("提示", "当前没有会话档案", parent=win) return if not messagebox.askyesno( "清空全部", f"确定清空全部 {n} 条会话档案吗?不可恢复。", parent=win ): return deleted = st.clear_all() self._append(f"✓ 已清空全部会话档案({deleted})", "ok") _reload() tree.bind("<>", _on_select) tree.bind("", lambda e: _delete_selected()) self._mgr_action_btn(btn_row, "刷新", _reload).pack(side="left") self._mgr_action_btn(btn_row, "全选", _select_all).pack(side="left", padx=(10, 0)) self._mgr_action_btn(btn_row, "删除", _delete_one, danger=True).pack(side="left", padx=(10, 0)) self._mgr_action_btn(btn_row, "批量删除", _delete_selected, danger=True).pack(side="left", padx=(10, 0)) self._mgr_action_btn(btn_row, "清空全部", _clear_all, muted=True).pack(side="left", padx=(10, 0)) self._mgr_action_btn(btn_row, "关闭", win.destroy, muted=True).pack(side="left", padx=(10, 0)) _reload() def _refresh_ai_status(self): try: import ai_config if self._ai_var.get(): bits = [ai_config.AI_MODEL] if self._ctx_var.get(): bits.append("上下文") if self._insult_var.get(): bits.append("对骂") if self._mcp_var.get(): n = len(getattr(ai_config, "AI_MCP_SERVERS", []) or []) bits.append(f"MCP×{n}") try: from registration_store import RegistrationStore n = RegistrationStore().pending_count() if n: bits.append(f"待挂号{n}") except Exception: pass self._ai_status.config(text=" · ".join(bits), fg=MUTED) else: self._ai_status.config(text="已关闭 · 使用固定回复", fg=FAINT) self._refresh_registration_count() except ImportError: self._ai_status.config(text="ai_config.py 未找到", fg=ROSE) # ─────────── 挂号登记 ───────────────────────────── def _open_registration_leads(self): from registration_store import RegistrationStore import time as _time from tkinter import messagebox win, body, foot = self._open_mgr_shell( "挂号问诊登记", "客户明确要求挂号时自动登记。支持单删 / 批量删除;约上后点「已联系」归档。", ) status_lbl = tk.Label(foot, text="", bg=SURFACE, fg=MUTED, font=(FONT, 11)) status_lbl.pack(side="left") btn_row = tk.Frame(foot, bg=SURFACE) btn_row.pack(side="right") panes = tk.PanedWindow(body, orient="horizontal", bg=SURFACE, sashwidth=6, sashrelief="flat", bd=0, opaqueresize=True) panes.pack(fill="both", expand=True) left = tk.Frame(panes, bg=SURFACE) right = tk.Frame(panes, bg=SURFACE) panes.add(left, minsize=540, stretch="always") panes.add(right, minsize=340, stretch="always") list_card = tk.Frame(left, bg=SURFACE, highlightthickness=1, highlightbackground=LINE) list_card.pack(fill="both", expand=True, padx=(0, 8)) tk.Label(list_card, text="全部登记", bg=SURFACE, fg=MUTED, font=(FONT, 11, "bold")).pack(anchor="w", padx=16, pady=(14, 6)) tree_wrap = tk.Frame(list_card, bg=SURFACE) tree_wrap.pack(fill="both", expand=True, padx=8, pady=(0, 8)) store = RegistrationStore() status_map = { "pending_symptom": "待问清病症", "booked": "已预约待联系", "done": "已联系完成", } cols = ("time", "contact", "symptom", "status") tree = ttk.Treeview( tree_wrap, columns=cols, show="headings", selectmode="extended", style="Mgr.Treeview", ) tree.heading("time", text="时间") tree.heading("contact", text="微信客户") tree.heading("symptom", text="病症 / 诉求") tree.heading("status", text="状态") tree.column("time", width=120, minwidth=110, stretch=False, anchor="w") tree.column("contact", width=140, minwidth=120, stretch=False, anchor="w") tree.column("symptom", width=300, minwidth=180, stretch=True, anchor="w") tree.column("status", width=130, minwidth=110, stretch=False, anchor="w") vsb = ttk.Scrollbar(tree_wrap, orient="vertical", command=tree.yview) tree.configure(yscrollcommand=vsb.set) tree.grid(row=0, column=0, sticky="nsew") vsb.grid(row=0, column=1, sticky="ns") tree_wrap.rowconfigure(0, weight=1) tree_wrap.columnconfigure(0, weight=1) prev_card = tk.Frame(right, bg=SURFACE, highlightthickness=1, highlightbackground=LINE) prev_card.pack(fill="both", expand=True, padx=(8, 0)) tk.Label(prev_card, text="登记详情", bg=SURFACE, fg=MUTED, font=(FONT, 11, "bold")).pack(anchor="w", padx=16, pady=(14, 6)) detail = scrolledtext.ScrolledText( prev_card, bg="#0E0E12", fg=INK, font=(FONT, 12), relief="flat", bd=0, wrap="word", padx=16, pady=14, spacing1=4, spacing3=8, insertbackground=ACCENT, ) detail.pack(fill="both", expand=True, padx=8, pady=(0, 8)) detail.tag_config("label", foreground=MUTED, font=(FONT, 11)) detail.tag_config("value", foreground=INK, font=(FONT, 12), lmargin1=4, lmargin2=4) detail.tag_config("empty", foreground=MUTED, font=(FONT, 12)) detail.configure(state="disabled") def _fmt_ts(ts): try: return _time.strftime("%m-%d %H:%M", _time.localtime(float(ts))) except Exception: return "—" def _set_status(text): status_lbl.config(text=text) def _reload(): tree.delete(*tree.get_children()) leads = store.list_leads(include_done=True) for item in leads: tree.insert( "", "end", iid=item["id"], values=( _fmt_ts(item.get("updated") or item.get("created")), item.get("contact") or "未知客户", (item.get("symptom") or "(待问清)")[:60], status_map.get(item.get("status"), item.get("status")), ), ) detail.configure(state="normal") detail.delete("1.0", "end") detail.configure(state="disabled") _set_status(f"共 {len(leads)} 条 · 待处理 {store.pending_count()}") self._refresh_registration_count() self._refresh_ai_status() def _on_select(_=None): sel = tree.selection() detail.configure(state="normal") detail.delete("1.0", "end") if len(sel) == 1: lead = next((x for x in store.list_leads() if x.get("id") == sel[0]), None) if lead: pairs = [ ("微信客户", lead.get("contact") or "未知"), ("病症 / 诉求", lead.get("symptom") or "(待问清)"), ("状态", status_map.get(lead.get("status"), lead.get("status"))), ("最近客户消息", (lead.get("last_user") or "—")[:400]), ("最近客服回复", (lead.get("last_reply") or "—")[:400]), ] for label, value in pairs: detail.insert("end", f"{label}\n", "label") detail.insert("end", f"{value}\n\n", "value") _set_status("已选 1 条") elif len(sel) > 1: detail.insert("end", f"已选中 {len(sel)} 条登记。\n可批量删除,或批量标记已联系。", "empty") _set_status(f"已选 {len(sel)} 条") else: _set_status(f"共 {len(store.list_leads())} 条 · Ctrl / Shift 多选") detail.configure(state="disabled") def _select_all(): kids = tree.get_children() if kids: tree.selection_set(kids) _on_select() def _mark_done(): sel = list(tree.selection()) if not sel: messagebox.showinfo("提示", "请先选中要归档的记录", parent=win) return n = store.set_status_many(sel, "done") self._append(f"✓ 已标记 {n} 条挂号登记为已联系", "ok") _reload() def _delete_one(): sel = list(tree.selection()) if len(sel) != 1: messagebox.showinfo("提示", "请先选中 1 条要删除的记录", parent=win) return if not messagebox.askyesno("确认删除", "确定删除这条挂号登记?", parent=win): return store.delete(sel[0]) self._append("✓ 已删除 1 条挂号登记", "ok") _reload() def _delete_selected(): sel = list(tree.selection()) if not sel: messagebox.showinfo("提示", "请先选中要删除的记录(可多选)", parent=win) return if not messagebox.askyesno( "批量删除", f"确定删除选中的 {len(sel)} 条挂号登记吗?不可恢复。", parent=win, ): return n = store.delete_many(sel) self._append(f"✓ 已批量删除 {n} 条挂号登记", "ok") _reload() tree.bind("<>", _on_select) tree.bind("", lambda e: _delete_selected()) self._mgr_action_btn(btn_row, "刷新", _reload).pack(side="left") self._mgr_action_btn(btn_row, "全选", _select_all).pack(side="left", padx=(10, 0)) self._mgr_action_btn(btn_row, "已联系", _mark_done, primary=True).pack(side="left", padx=(10, 0)) self._mgr_action_btn(btn_row, "删除", _delete_one, danger=True).pack(side="left", padx=(10, 0)) self._mgr_action_btn(btn_row, "批量删除", _delete_selected, danger=True).pack(side="left", padx=(10, 0)) self._mgr_action_btn(btn_row, "关闭", win.destroy, muted=True).pack(side="left", padx=(10, 0)) _reload() # ─────────── MCP 服务器配置 ─────────────────────── def _open_mcp_config(self): try: import ai_config except ImportError: self._append("ai_config.py 未找到", "err") return win = tk.Toplevel(self) win.title("MCP 服务器") win.configure(bg=BG) win.geometry("720x560") win.minsize(640, 480) win.grab_set() card = tk.Frame(win, bg=SURFACE, highlightthickness=1, highlightbackground=LINE) card.pack(fill="both", expand=True, padx=16, pady=16) tk.Label(card, text="MCP 服务器", bg=SURFACE, fg=INK, font=(FONT, FS_H1)).pack(anchor="w", padx=20, pady=(18, 2)) tk.Label( card, text="自动回复时可调用这些工具。保存后立即生效。", bg=SURFACE, fg=MUTED, font=(FONT, FS_META), wraplength=640, justify="left", ).pack(anchor="w", padx=20) example = ( '[\n' ' {\n' ' "name": "filesystem",\n' ' "enabled": true,\n' ' "transport": "stdio",\n' ' "command": "npx",\n' ' "args": ["-y", "@modelcontextprotocol/server-filesystem", "D:/docs"],\n' ' "env": {},\n' ' "cwd": null\n' ' }\n' ']' ) servers = getattr(ai_config, "AI_MCP_SERVERS", None) or [] try: initial = json.dumps(servers, ensure_ascii=False, indent=2) except Exception: initial = "[]" if initial.strip() in ("[]", "null"): initial = example editor = scrolledtext.ScrolledText( card, bg="#0E0E12", fg=INK, insertbackground=ACCENT, font=(FONT_MONO, FS_META), relief="flat", bd=0, wrap="none", padx=12, pady=12, height=18, highlightthickness=1, highlightbackground=LINE) editor.pack(fill="both", expand=True, padx=20, pady=(12, 8)) editor.insert("1.0", initial) rounds_row = tk.Frame(card, bg=SURFACE) rounds_row.pack(fill="x", padx=20, pady=(0, 8)) tk.Label(rounds_row, text="单次回复最多工具轮数", bg=SURFACE, fg=MUTED, font=(FONT, FS_BODY)).pack(side="left") rounds_var = tk.StringVar(value=str(getattr(ai_config, "AI_MCP_MAX_ROUNDS", 5))) tk.Entry(rounds_row, textvariable=rounds_var, width=6, bg=SURFACE2, fg=INK, insertbackground=TEAL, relief="flat", font=(FONT_MONO, FS_BODY), highlightthickness=1, highlightbackground=LINE ).pack(side="left", padx=(10, 0), ipady=4) status = tk.Label(card, text="", bg=SURFACE, fg=MUTED, font=(FONT, FS_META), anchor="w", justify="left") status.pack(fill="x", padx=20) def _parse_servers(): raw = editor.get("1.0", "end").strip() data = json.loads(raw) if not isinstance(data, list): raise ValueError("根节点必须是 JSON 数组") return data def _test(): try: servers = _parse_servers() except Exception as e: status.config(text=f"JSON 无效: {e}", fg=ROSE) return status.config(text="正在探测 MCP 服务器…", fg=AMBER) win.update_idletasks() def work(): try: from mcp_bridge import run_coro, probe_servers result = run_coro(probe_servers(servers)) tools = result.get("tools") or [] names = ", ".join(result.get("servers") or []) or "无" msg = (f"连通 {len(result.get('servers') or [])} 台 · " f"工具 {result.get('tool_count', 0)} 个 · 服务器: {names}") if tools: sample = "、".join(t["name"] for t in tools[:6]) msg += f"\n示例: {sample}" self.after(0, lambda: status.config(text=msg, fg=TEAL)) except Exception as e: self.after(0, lambda: status.config(text=f"探测失败: {e}", fg=ROSE)) threading.Thread(target=work, daemon=True).start() def _save(): try: servers = _parse_servers() rounds = int(rounds_var.get().strip()) if rounds < 1: rounds = 1 except Exception as e: status.config(text=f"保存失败: {e}", fg=ROSE) return ai_config.AI_MCP_SERVERS = servers ai_config.AI_MCP_MAX_ROUNDS = rounds ai_config.AI_MCP_ENABLED = self._mcp_var.get() try: ai_config.save_settings() self._append("✓ MCP 服务器配置已保存", "ok") self._refresh_ai_status() except Exception as e: status.config(text=f"写入失败: {e}", fg=ROSE) return win.destroy() btns = tk.Frame(card, bg=SURFACE) btns.pack(pady=(4, 18)) NeonButton(btns, "测试连通", command=_test, width=110, height=40, bg=SURFACE).pack(side="left", padx=6) NeonButton(btns, "保存", command=_save, width=100, height=40, primary=True, bg=SURFACE).pack(side="left", padx=6) NeonButton(btns, "取消", command=win.destroy, width=100, height=40, bg=SURFACE).pack(side="left", padx=6) # ─────────── AI 高级配置对话框 ─────────────────── def _open_ai_config(self): try: import ai_config except ImportError: self._append("ai_config.py 未找到", "err") return win = tk.Toplevel(self) win.title("AI 高级配置") win.configure(bg=BG) win.resizable(False, False) win.grab_set() card = tk.Frame(win, bg=SURFACE, highlightthickness=1, highlightbackground=LINE) card.pack(padx=16, pady=16) tk.Label(card, text="AI 高级配置", bg=SURFACE, fg=INK, font=(FONT, FS_H1)).pack(anchor="w", padx=20, pady=(18, 2)) tk.Label(card, text="保存后立即生效,并写入 ai_settings.json", bg=SURFACE, fg=MUTED, font=(FONT, FS_META)).pack(anchor="w", padx=20) body = tk.Frame(card, bg=SURFACE) body.pack(padx=20, pady=14) fields = [ ("API 地址", "AI_API_BASE", str), ("API Key", "AI_API_KEY", str), ("模型名称", "AI_MODEL", str), ("客服昵称", "AI_AGENT_NAME", str), ("医院名称", "AI_HOSPITAL_NAME", str), ("上下文记忆轮数", "AI_CONTEXT_MAX_ROUNDS", int), ("最大回复 tokens", "AI_MAX_TOKENS", int), ("温度 (0~1.5)", "AI_TEMPERATURE", float), ("请求超时(秒)", "AI_TIMEOUT", int), ] entry_vars = {} for i, (label, key, _typ) in enumerate(fields): tk.Label(body, text=label, bg=SURFACE, fg=MUTED, font=(FONT, FS_BODY)).grid(row=i, column=0, sticky="w", pady=5) v = tk.StringVar(value=str(getattr(ai_config, key))) box = tk.Frame(body, bg=SURFACE2, highlightthickness=1, highlightbackground=LINE) box.grid(row=i, column=1, sticky="we", padx=(14, 0), pady=5) tk.Entry(box, textvariable=v, width=42, bg=SURFACE2, fg=INK, insertbackground=TEAL, relief="flat", font=(FONT_MONO, FS_META), bd=0, highlightthickness=0 ).pack(fill="x", padx=10, pady=8) entry_vars[key] = v vision_var = tk.BooleanVar(value=ai_config.AI_USE_VISION) Toggle(body, vision_var, "视觉模式(截图发给多模态模型)" ).grid(row=len(fields), column=0, columnspan=2, sticky="w", pady=(10, 0)) def _save(): for label, key, typ in fields: raw = entry_vars[key].get().strip() try: val = typ(raw) except ValueError: self._append(f"配置「{label}」格式不正确: {raw}", "err") return setattr(ai_config, key, val) ai_config.AI_USE_VISION = vision_var.get() ai_config.AI_ENABLED = self._ai_var.get() ai_config.AI_CONTEXT_ENABLED = self._ctx_var.get() ai_config.AI_COUNTER_INSULT_ENABLED = self._insult_var.get() ai_config.AI_MCP_ENABLED = self._mcp_var.get() ai_config.AI_SYSTEM_PROMPT = ai_config.build_system_prompt() try: ai_config.save_settings() self._append("✓ AI 配置已保存并生效", "ok") self._refresh_ai_status() except Exception as e: self._append(f"配置保存失败: {e}", "err") return win.destroy() btns = tk.Frame(card, bg=SURFACE) btns.pack(pady=(4, 18)) NeonButton(btns, "保存", command=_save, width=100, height=40, primary=True, bg=SURFACE).pack(side="left", padx=6) NeonButton(btns, "取消", command=win.destroy, width=100, height=40, bg=SURFACE).pack(side="left", padx=6) # ─────────── 逻辑 ─────────────────────────────── def _on_start(self): reply = self._reply_var.get().strip() or "你好" try: poll = float(self._poll_var.get()) except ValueError: poll = 2.0 try: idle_sec = float(self._idle_sec_var.get()) if idle_sec < 0: idle_sec = 0 except ValueError: idle_sec = float(MOUSE_IDLE_SECONDS) mouse_idle_on = self._mouse_idle_var.get() ai_on = self._ai_var.get() ctx_on = self._ctx_var.get() insult_on = self._insult_var.get() mcp_on = self._mcp_var.get() try: import ai_config ai_config.AI_ENABLED = ai_on ai_config.AI_CONTEXT_ENABLED = ctx_on ai_config.AI_COUNTER_INSULT_ENABLED = insult_on ai_config.AI_MCP_ENABLED = mcp_on try: ai_config.save_settings() except Exception: pass self._refresh_ai_status() except ImportError: self._ai_status.config(text="ai_config.py 未找到", fg=ROSE) sys.stdout = LogQueue(self._q) self._thread = BotThread( self._q, reply, poll, mouse_idle_enabled=mouse_idle_on, mouse_idle_sec=idle_sec, ) self._start_time = time.time() self._running = True self._thread.start() self._start_btn.set_enabled(False) self._stop_btn.set_enabled(True) self._set_status("connecting", "连接中…") self._append("▶ 机器人启动中…", "notify") def _on_stop(self): self._running = False if self._thread: self._thread.stop() sys.stdout = sys.__stdout__ self._start_btn.set_enabled(True) self._stop_btn.set_enabled(False) self._set_status("stopped", "已停止") self._append("■ 监听已停止", "warn") def _set_status(self, state, text): self._status_key = state c = {"connecting": AMBER, "running": GREEN, "error": ROSE, "stopped": FAINT}.get(state, FAINT) self._sdot.itemconfig(self._sdot_id, fill=c) self._s_lbl.config(text=text, fg=MUTED if state == "stopped" else c) def _append(self, msg, tag=""): ts = time.strftime("%H:%M:%S") self._log.config(state="normal") self._log.insert("end", f"[{ts}] ", "dim") self._log.insert("end", msg + "\n", tag) self._log.see("end") self._log.config(state="disabled") def _clear_log(self): self._log.config(state="normal") self._log.delete("1.0", "end") self._log.config(state="disabled") # ─────────── 队列消费 ──────────────────────────── def _tick(self): try: while True: kind, data = self._q.get_nowait() if kind == "log": if any(k in data for k in ("✓", "回复完成")): self._append(data, "ok") elif any(k in data for k in ("⚠", "假阳性")): self._append(data, "warn") elif any(k in data for k in ("错误", "[-]")): self._append(data, "err") elif any(k in data for k in ("🔔", "未读红点", "🖱", "人手", "[MCP]", "[挂号]")): self._append(data, "notify") if "[挂号]" in data: self._refresh_registration_count() else: self._append(data) elif kind == "status": if data == "running": self._set_status("running", "监听中") elif data == "error": self._set_status("error", "连接失败") self._on_stop() elif data == "stopped": self._set_status("stopped", "已停止") elif kind == "info": self._hwnd_lbl.config(text=data["hwnd"]) self._size_lbl.config(text=data["size"]) self._ibox_lbl.config(text=data["input"]) elif kind == "stats": self._lbl_replied.config(text=str(data["replied"])) self._lbl_fp.config(text=str(data["false_pos"])) except queue.Empty: pass if self._start_time and self._running: e = int(time.time() - self._start_time) self._timer_lbl.config( text=f"运行时长 {e//3600:02d}:{(e%3600)//60:02d}:{e%60:02d}") self.after(200, self._tick) def on_close(self): self._running = False if self._thread: self._thread.stop() sys.stdout = sys.__stdout__ self.destroy() if __name__ == "__main__": app = App() app.protocol("WM_DELETE_WINDOW", app.on_close) app.mainloop()