# -*- coding: utf-8 -*- """被控端唯一的窗口:首次设置窗口(重做版)。 整个程序只有这一个窗口,而且只在**第一次运行**时出现一次: 双击 exe → 弹出这个窗口:设 6 位配对码 + 勾「开机自启」「放行防火墙」 → 点确定 → 先检查这个码在服务器上有没有被占 被占就弹框让你换,换好了才继续 → 黑终端一闪(放行防火墙) → 防火墙没放行成功的话,**不把码交给服务器**,并说明原因 → 之后永远后台静默,屏幕上什么都没有 → 手表输这 6 位数字就连上了 界面重做的要点: * 用作者给的图标(原来是代码画的一个蓝方块) * 分区卡片:配对码单独一块,选项单独一块,不再是一堆裸控件 * 配对码框大字号、居中、字距拉开,一眼能看清 * 状态提示带颜色(红=错误,黄=提醒,绿=通过) * 配对码统一成 **6 位数字**(中转就是要这个格式, 以前允许 6-16 位字母数字,用户填 10 位会在检查那步卡死) """ from __future__ import annotations import tkinter as tk from pathlib import Path # ---------------------------------------------------------------------- # 配色(浅色,和手表端一致) # ---------------------------------------------------------------------- BG = "#eef1f6" CARD = "#ffffff" FG = "#1f2328" ACCENT = "#185ce8" ACCENT_DARK = "#0f47bd" OK = "#1a7f37" WARN = "#9a6700" DANGER = "#cf222e" DIM = "#656d76" MUTED = "#8c959f" BORDER = "#d8dee4" FONT_UI = ("Microsoft YaHei UI", 10) FONT_TITLE = ("Microsoft YaHei UI", 17, "bold") FONT_SUB = ("Microsoft YaHei UI", 9) FONT_SMALL = ("Microsoft YaHei UI", 9) FONT_LABEL = ("Microsoft YaHei UI", 10, "bold") FONT_CODE = ("Consolas", 26, "bold") FONT_BTN = ("Microsoft YaHei UI", 10, "bold") # 图标源图(作者给的) ICON_SOURCE = Path(__file__).resolve().parent.parent.parent / "_build" / "icon_source.png" def make_icon_image(size: int = 64): """取图标。优先用作者给的那张图,读不到再退回代码画的那个。""" try: from PIL import Image if ICON_SOURCE.is_file(): img = Image.open(ICON_SOURCE).convert("RGBA") width, height = img.size side = min(width, height) left = (width - side) // 2 top = (height - side) // 2 img = img.crop((left, top, left + side, top + side)) return img.resize((size, size), Image.LANCZOS) except Exception: # noqa: BLE001 pass return _drawn_icon(size) ICON_BLUE = (24, 92, 232, 255) def _drawn_icon(size: int = 64): """兜底:代码画一个蓝底手表。""" from PIL import Image, ImageDraw img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) margin = max(1, size // 16) draw.rounded_rectangle([margin, margin, size - margin, size - margin], radius=max(2, size // 5), fill=ICON_BLUE) cx = size // 2 box_w = int(size * 0.36) box_h = int(size * 0.30) top = int(size * 0.35) strap_w = int(size * 0.16) draw.rounded_rectangle([cx - strap_w // 2, top - int(size * 0.10), cx + strap_w // 2, top], radius=max(1, size // 32), fill=(255, 255, 255, 255)) draw.rounded_rectangle([cx - strap_w // 2, top + box_h, cx + strap_w // 2, top + box_h + int(size * 0.10)], radius=max(1, size // 32), fill=(255, 255, 255, 255)) draw.rounded_rectangle([cx - box_w // 2, top, cx + box_w // 2, top + box_h], radius=max(2, size // 16), fill=(255, 255, 255, 255)) return img class SetupWindow: """首次使用的设置窗口。 用法: result = SetupWindow(store).show() if result is None: # 用户关掉了 ... result["code"] # 用户设的配对码 result["autostart"] # 要不要开机自启 result["firewall"] # 要不要顺手放行防火墙 """ CODE_LEN = 6 def __init__(self, store, suggested: str = ""): self.store = store self.suggested = (suggested or store.pairing_code() or store.new_pairing_code()) self.result: dict | None = None self.root: tk.Tk | None = None self._after_ids: list = [] self._icon = None # ------------------------------------------------------------------ def show(self, on_ready=None) -> dict | None: """弹出窗口并阻塞,直到用户点确定或关掉。 on_ready 是窗口建好之后的回调(自动化测试用)。 """ self.root = tk.Tk() self.root.title("ykxmz · 第一次设置") self.root.configure(bg=BG) self.root.resizable(False, False) self._set_icon() self._build() self.root.update_idletasks() if on_ready is not None: def _fire(): if self._alive(): on_ready(self) self._after_ids.append(self.root.after(300, _fire)) self._center() self.root.lift() self.root.attributes("-topmost", True) def _drop_topmost(): if self._alive(): try: self.root.attributes("-topmost", False) except tk.TclError: pass self._after_ids.append(self.root.after(700, _drop_topmost)) self.root.mainloop() for after_id in self._after_ids: try: self.root.after_cancel(after_id) except Exception: # noqa: BLE001 pass self._after_ids = [] try: self.root.destroy() except tk.TclError: pass self.root = None return self.result def _alive(self) -> bool: """窗口还在吗。after 回调必须在销毁后自己退出,否则会报 invalid command name。""" try: return self.root is not None and bool(self.root.winfo_exists()) except tk.TclError: return False def _set_icon(self) -> None: try: from PIL import ImageTk self._icon = ImageTk.PhotoImage(make_icon_image(64)) self.root.iconphoto(True, self._icon) except Exception: # noqa: BLE001 pass def _center(self) -> None: width = self.root.winfo_reqwidth() height = self.root.winfo_reqheight() screen_w = self.root.winfo_screenwidth() screen_h = self.root.winfo_screenheight() x = max(0, (screen_w - width) // 2) y = max(0, (screen_h - height) // 3) self.root.geometry(f"{width}x{height}+{x}+{y}") # ------------------------------------------------------------------ def _card(self, parent) -> tk.Frame: """一个白底圆角感的卡片(tkinter 没有圆角,用边框色模拟)。 返回的是**外层** frame —— 调用处要 pack 它。 踩过的坑:一开始返回 inner,而 inner 的父控件是 outer, 于是 inner.pack() 挂回 outer,outer 自己从没被 pack, 结果卡片完全不显示(窗口高度只剩 199px)。 """ outer = tk.Frame(parent, bg=BORDER) inner = tk.Frame(outer, bg=CARD) inner.pack(fill="both", expand=True, padx=1, pady=1) return outer def _build(self) -> None: root = self.root # ---- 顶部强调条 ---- tk.Frame(root, bg=ACCENT, height=5).pack(fill="x") page = tk.Frame(root, bg=BG) page.pack(fill="both", expand=True, padx=20, pady=18) # ================= 标题区 ================= header = tk.Frame(page, bg=BG) header.pack(fill="x") try: from PIL import ImageTk badge = ImageTk.PhotoImage(make_icon_image(46)) icon_label = tk.Label(header, image=badge, bg=BG, bd=0) icon_label.image = badge # 留引用 icon_label.pack(side="left", padx=(0, 12)) except Exception: # noqa: BLE001 pass titles = tk.Frame(header, bg=BG) titles.pack(side="left", anchor="w") tk.Label(titles, text="ykxmz", bg=BG, fg=FG, font=FONT_TITLE).pack( anchor="w") tk.Label(titles, text="远程控制 · 第一次设置", bg=BG, fg=DIM, font=FONT_SUB).pack(anchor="w") # ================= 配对码卡片 ================= card = self._card(page) card.pack(fill="x", pady=(16, 0)) inner = tk.Frame(card.winfo_children()[0], bg=CARD) inner.pack(fill="x", padx=16, pady=14) tk.Label(inner, text="配对码", bg=CARD, fg=FG, font=FONT_LABEL).pack( anchor="w") code_row = tk.Frame(inner, bg=CARD) code_row.pack(fill="x", pady=(8, 0)) code_box = tk.Frame(code_row, bg="#f6f8fa", highlightthickness=1, highlightbackground=BORDER, highlightcolor=ACCENT) code_box.pack(side="left") self.entry = tk.Entry( code_box, bg="#f6f8fa", fg=ACCENT_DARK, relief="flat", font=FONT_CODE, width=7, justify="center", insertbackground=ACCENT) self.entry.insert(0, self.suggested) self.entry.pack(padx=10, pady=6) self.entry.select_range(0, "end") self.entry.focus_set() # 只允许输入数字,且最多 6 位 self.entry.bind("", self._on_code_key) self._flat_button(code_row, "换一个", self._reroll).pack( side="left", padx=(10, 0)) tk.Label(inner, text="6 位数字。手表上输这 6 位就能连上这台电脑。", bg=CARD, fg=MUTED, font=FONT_SMALL).pack( anchor="w", pady=(8, 0)) self.status = tk.Label(inner, text="", bg=CARD, fg=DANGER, font=FONT_SMALL, wraplength=330, justify="left", anchor="w") self.status.pack(fill="x", pady=(6, 0)) # ================= 选项卡片 ================= options = self._card(page) options.pack(fill="x", pady=(12, 0)) opt_inner = tk.Frame(options.winfo_children()[0], bg=CARD) opt_inner.pack(fill="x", padx=16, pady=12) tk.Label(opt_inner, text="选项", bg=CARD, fg=FG, font=FONT_LABEL).pack(anchor="w", pady=(0, 6)) self.var_auto = tk.BooleanVar(value=True) self._check(opt_inner, "开机自动运行", self.var_auto, "开机后在后台跑,不用手动开") self.var_firewall = tk.BooleanVar(value=True) self._check(opt_inner, "放行防火墙", self.var_firewall, "会弹一次管理员确认,然后黑窗口一闪而过") tk.Label(opt_inner, text="没放行成功的话,配对码不会提交给服务器," "会告诉你怎么处理。", bg=CARD, fg=MUTED, font=FONT_SMALL, justify="left", wraplength=396).pack(anchor="w", pady=(6, 0)) # ================= 按钮区 ================= buttons = tk.Frame(page, bg=BG) buttons.pack(fill="x", pady=(16, 0)) primary = tk.Button( buttons, text="确定,开始使用", command=self._confirm, relief="flat", bd=0, font=FONT_BTN, cursor="hand2", bg=ACCENT, fg="#ffffff", activebackground=ACCENT_DARK, activeforeground="#ffffff", padx=22, pady=10) primary.pack(side="left") tk.Button( buttons, text="取消", command=self._cancel, relief="flat", bd=0, font=FONT_UI, cursor="hand2", bg=BG, fg=DIM, activebackground="#e2e6ec", activeforeground=FG, padx=14, pady=10).pack(side="left", padx=(8, 0)) tk.Label(page, text="设完之后程序就在后台跑,不会再出现任何窗口。" "需要重设就跑 ykxmz.exe --setup", bg=BG, fg=MUTED, font=FONT_SMALL).pack(anchor="w", pady=(12, 0)) root.bind("", lambda _e: self._confirm()) root.bind("", lambda _e: self._cancel()) root.protocol("WM_DELETE_WINDOW", self._cancel) def _check(self, parent, title: str, variable, hint: str) -> None: """一行勾选框 + 灰字说明。""" row = tk.Frame(parent, bg=CARD) row.pack(fill="x", pady=(2, 0)) tk.Checkbutton(row, text=title, variable=variable, bg=CARD, fg=FG, activebackground=CARD, activeforeground=FG, selectcolor="#ffffff", font=FONT_UI, bd=0, highlightthickness=0, cursor="hand2").pack(side="left") tk.Label(row, text=hint, bg=CARD, fg=MUTED, font=FONT_SMALL).pack(side="left", padx=(8, 0)) def _flat_button(self, parent, text, command, primary: bool = False): button = tk.Button( parent, text=text, command=command, relief="flat", bd=0, font=FONT_UI if not primary else FONT_BTN, cursor="hand2", bg="#eaeef2" if not primary else ACCENT, fg=FG if not primary else "#ffffff", activebackground="#dfe3e8" if not primary else ACCENT_DARK, activeforeground=FG if not primary else "#ffffff", padx=12, pady=6) return button # ------------------------------------------------------------------ def _on_code_key(self, _event=None) -> None: """输入时过滤:只留数字,最多 6 位。""" raw = self.entry.get() cleaned = "".join(ch for ch in raw if ch.isdigit())[:self.CODE_LEN] if cleaned != raw: position = self.entry.index("insert") self.entry.delete(0, "end") self.entry.insert(0, cleaned) self.entry.icursor(min(position, len(cleaned))) if len(cleaned) == self.CODE_LEN: self._set_status("", MUTED) def _set_status(self, text: str, color: str) -> None: try: self.status.configure(text=text, fg=color) except tk.TclError: pass def _reroll(self) -> None: self.entry.delete(0, "end") self.entry.insert(0, self.store.new_pairing_code()) self._set_status("", MUTED) self.entry.focus_set() def _confirm(self) -> None: typed = self.entry.get().strip() cleaned = "".join(ch for ch in typed if ch.isdigit()) if not cleaned: self._set_status("请填一个 6 位数字", DANGER) return if len(cleaned) != self.CODE_LEN: self._set_status( f"配对码要正好 {self.CODE_LEN} 位数字,现在是 {len(cleaned)} 位", DANGER) return self.result = { "code": cleaned, "autostart": bool(self.var_auto.get()), "firewall": bool(self.var_firewall.get()), } try: self.root.quit() except tk.TclError: pass def _cancel(self) -> None: self.result = None try: self.root.quit() except tk.TclError: pass