


一个基于 Python 标准库
tkinter的桌面 GUI 程序,集中演示 5 种常见验证码 的实现原理与验证效果。零第三方依赖,双击即可运行。
验证码方式演示器(captcha_demo.py)是一个用于教学与演示的桌面小工具,把业界最常见的几类人机验证方式集中在一个窗口里,直观展示它们各自的实现原理和验证交互过程。
tkinter | |
datetime_viewer.py 保持一致 | |
设计目标:
⚠️ 说明:本项目为原理演示用途,所有校验均在客户端本地完成,不具备真实生产环境所需的服务端安全强度(如轨迹风控、加密下发等),请勿直接用于生产。
sudo apt install python3-tk)python captcha_demo.py程序启动后会弹出一个 640 × 560 的窗口并自动居中,顶部为标题,下方为 5 个标签页(Tab),点击切换即可体验不同验证码。
┌───────────────────────────────────────────────┐│ 🔐 验证码方式演示器 ││ 展示常见验证码的实现原理与验证效果 │├───────────────────────────────────────────────┤│ [🔤字符] [➕算术] [🧩滑块] [👆点选] [📱短信] │ ← Notebook 标签页│ ││ ┌─────────────────────────────────────┐ ││ │ 当前标签页的演示内容 │ ││ │ (画布 / 输入框 / 按钮 / 结果反馈) │ ││ └─────────────────────────────────────┘ ││ │└───────────────────────────────────────────────┘ttk.Notebook 组织 5 个功能页程序由单一类 CaptchaDemo 承载,构造函数创建主窗口并调用 _build_ui() 组装界面。5 种验证码各自对应一组「构建 + 刷新 + 验证」方法,彼此独立、互不干扰。
CaptchaDemo├── __init__() 初始化窗口、居中、调用 _build_ui├── _build_ui() 创建标题 + Notebook,循环挂载 5 个 Tab│├── 【通用工具】│ ├── _result_label() 创建结果反馈标签│ ├── _show_result() 统一设置成功/失败文案与颜色│ ├── _hint() 创建灰色原理说明文字│ └── _draw_noise() 在画布上绘制干扰线 + 噪点│├── 【Tab1 字符验证码】│ ├── _build_char_tab()│ ├── _refresh_char_captcha()│ └── _verify_char()│├── 【Tab2 算术验证码】│ ├── _build_math_tab()│ ├── _refresh_math_captcha()│ └── _verify_math()│├── 【Tab3 滑块验证码】│ ├── _build_slider_tab()│ ├── _reset_slider()│ ├── _slider_press() / _slider_drag() / _slider_release()│├── 【Tab4 点选验证码】│ ├── _build_click_tab()│ ├── _reset_click_captcha()│ └── _on_click_captcha()│├── 【Tab5 短信验证码】│ ├── _build_sms_tab()│ ├── _send_sms()│ ├── _sms_tick() 倒计时 + 有效期刷新(after 循环)│ └── _verify_sms()│└── run() 进入 mainloop文件顶部集中定义了配色常量(BG / CARD / ACCENT / GREEN / RED 等)、字体常量(F_TITLE / F_BODY / F_MONO 等)以及三个字符池:
CHAR_POOL | 0 O 1 l I |
HANZI_POOL | |
NOISE_COLORSCHAR_COLORS |
原理: 随机生成 4 位字符,逐个渲染到画布上,每个字符施加随机的位置偏移、旋转角度、字号和颜色,再叠加干扰线与噪点,使机器难以通过 OCR 识别,而人眼仍可辨认。
关键实现(_refresh_char_captcha):
random.choices(CHAR_POOL, k=4) 生成验证码文本x 基准间隔 50px ± 6px 抖动,y 上下 ±10px,angle 在 -30°~30° 之间,字号 22~30 随机_draw_noise() 铺设 6 条干扰线 + 60 个噪点验证逻辑(_verify_char):
原理: 随机生成一道简单四则运算题(加 / 减 / 乘),渲染成扭曲图片。用户需先理解语义再计算,比纯字符识别多一层认知门槛。
关键实现(_refresh_math_captcha):
+ | ||
- | ||
× |
37 + 8 = ?,逐字符扭曲绘制(角度 ±15°)验证逻辑(_verify_math):
int() 解析输入,非数字自动判为失败原理: 页面展示一张带缺口的背景图和一个拼图块,用户拖动滑块使拼图块水平对齐缺口。真实系统除校验最终落点外,还会分析拖动轨迹(人手拖动存在细微抖动和加减速,而机器脚本轨迹往往过于平滑匀速)。
关键实现:
slider_canvas 显示背景 + 缺口 + 拼图块,下方 track_canvas 是可拖动的滑轨_reset_slider():随机生成缺口横坐标 gap_x(避开左侧起始区),拼图块起点固定在最左ratio 映射到画布行程,实现滑块与拼图块联动三段式拖动事件:
_slider_press | ||
_slider_drag | (时间, x) 轨迹 | |
_slider_release |
验证逻辑:
TOLERANCE(6px)判为通过,拼图块变绿原理: 在图片上随机散布多个汉字,要求用户按指定顺序点击其中几个。同时考验文字识别能力与语序理解,是「12306」式验证码的简化版。
关键实现(_reset_click_captcha):
HANZI_POOL 中 random.sample 选取 5 个不重复汉字,前 3 个作为需点击的目标验证逻辑(_on_click_captcha):
CLICK_R(22px)为判定半径寻找命中的汉字clicked_seq原理: 服务端生成 6 位随机数字下发到用户手机,附带有效期和重发冷却限制,用于验证「用户持有该手机号」这一事实。本项目用一个「模拟短信收件箱」面板展示收到的验证码。
关键实现:
1 开头的 11 位纯数字random.choices(string.digits, k=6)**** 遮挡(138****1234)_sms_tick): 通过 root.after(1000, ...) 每秒刷新SMS_RESEND_SEC(60 秒)内按钮禁用并显示剩余秒数SMS_VALID_SEC(120 秒)后验证码自动失效验证逻辑(_verify_sms):
_draw_noise被字符、算术、点选三种验证码复用,在指定画布上绘制随机干扰线与噪点,参数 lines / dots 可控制干扰强度。
_show_result统一的成功 / 失败展示入口:成功显示绿色文案,失败显示红色文案,各 Tab 传入自定义的成功 / 失败文本。
root.after(毫秒, 回调) 实现,不阻塞主线程after 自循环,并在成功 / 过期时通过 after_cancel 及时取消,避免任务堆积以下参数均定义为类属性或模块常量,可按需调整:
640 × 560 | ||
TOLERANCE | 6 | |
SLIDER_W / SLIDER_H | 480 / 160 | |
PIECE_SIZE | 44 | |
CLICK_W / CLICK_H | 480 / 200 | |
CLICK_R | 22 | |
SMS_RESEND_SEC | 60 | |
SMS_VALID_SEC | 120 | |
4 | _refresh_char_captcha 中的 k=4 | |
3 | chars[:3] |
Q1:运行报错 ModuleNotFoundError: No module named 'tkinter'?A:部分 Linux 发行版未默认安装 tkinter,执行 sudo apt install python3-tk(Debian/Ubuntu)即可。Windows / macOS 官方 Python 一般自带。
Q2:字符 / 汉字显示为方框或乱码?A:这是字体缺失问题。程序使用「Consolas」和「Microsoft YaHei」,非 Windows 系统可将代码中的字体名替换为系统已有字体。
Q3:滑块拖不动?A:需按住滑轨上的蓝色滑块(而非空白轨道)再拖动,_slider_press 只在点中滑块时才启动拖动。
Q4:这个能防住真实的机器人吗?A:不能。本项目所有验证均在客户端完成,仅用于演示原理。真实场景需服务端生成、校验,并配合加密、风控、轨迹分析等手段。
文件路径:
captcha_demo.py
# -*- coding: utf-8 -*-"""验证码方式演示器 - 浅色主题 GUI功能:展示常见验证码的实现与验证效果 1. 字符图形验证码(扭曲字符 + 干扰线/噪点) 2. 算术验证码(随机四则运算) 3. 滑块拼图验证码(拖动滑块对齐缺口) 4. 文字点选验证码(按顺序点击指定文字) 5. 短信验证码(模拟发送 + 倒计时 + 有效期)依赖:tkinter(内置)"""import tkinter as tkfrom tkinter import ttkimport randomimport stringimport time# ══════ 浅色主题配色(与 datetime_viewer 保持一致)══════BG = "#f8fafc"CARD = "#ffffff"BORDER = "#e2e8f0"TEXT = "#1e293b"TEXT_SEC = "#64748b"TEXT_WEAK = "#94a3b8"ACCENT = "#3b82f6"GREEN = "#10b981"YELLOW = "#f59e0b"RED = "#ef4444"BTN_BG = "#eff6ff"BTN_FG = "#1d4ed8"INPUT_BG = "#f1f5f9"F_TITLE = ("Microsoft YaHei", 11, "bold")F_BODY = ("Microsoft YaHei", 10)F_SMALL = ("Microsoft YaHei", 9)F_MONO = ("Consolas", 11)F_BIG_MONO = ("Consolas", 20, "bold")# 验证码字符集(去掉易混淆的 0O1lI)CHAR_POOL = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz"# 点选验证码汉字池HANZI_POOL = "春夏秋冬风花雪月山水木火土日星云雨虹桥船车马牛羊鱼鸟龙"# 干扰色NOISE_COLORS = ["#94a3b8", "#cbd5e1", "#fca5a5", "#93c5fd", "#86efac", "#fcd34d"]CHAR_COLORS = ["#1d4ed8", "#dc2626", "#059669", "#7c3aed", "#db2777", "#ea580c"]classCaptchaDemo:def__init__(self): self.root = tk.Tk() self.root.title("验证码方式演示器") sw = self.root.winfo_screenwidth() sh = self.root.winfo_screenheight() w, h = 640, 560 self.root.geometry(f"{w}x{h}+{(sw-w)//2}+{(sh-h)//2}") self.root.configure(bg=BG) self.root.resizable(False, False) self._build_ui()# ══════ 总体布局 ══════def_build_ui(self): tk.Label(self.root, text="🔐 验证码方式演示器", font=("Microsoft YaHei", 14, "bold"), bg=BG, fg=TEXT).pack(pady=(14, 2)) tk.Label(self.root, text="展示常见验证码的实现原理与验证效果", font=F_SMALL, bg=BG, fg=TEXT_SEC).pack(pady=(0, 8)) style = ttk.Style() style.configure("TNotebook", background=BG) style.configure("TNotebook.Tab", background=BTN_BG, foreground=TEXT, padding=[10, 4], font=F_SMALL) style.map("TNotebook.Tab", background=[("selected", CARD)], foreground=[("selected", ACCENT)]) notebook = ttk.Notebook(self.root) notebook.pack(fill="both", expand=True, padx=16, pady=(0, 12)) tabs = [ (" 🔤 字符验证码 ", self._build_char_tab), (" ➕ 算术验证码 ", self._build_math_tab), (" 🧩 滑块验证码 ", self._build_slider_tab), (" 👆 点选验证码 ", self._build_click_tab), (" 📱 短信验证码 ", self._build_sms_tab), ]for text, builder in tabs: frame = tk.Frame(notebook, bg=CARD) notebook.add(frame, text=text) builder(frame)# ══════ 通用小部件 ══════def_result_label(self, parent): lbl = tk.Label(parent, text="", font=F_TITLE, bg=CARD, fg=TEXT_SEC) lbl.pack(anchor="w", padx=16, pady=(8, 4))return lbldef_show_result(self, lbl, ok, ok_text="✅ 验证通过", fail_text="❌ 验证失败"): lbl.configure(text=ok_text if ok else fail_text, fg=GREEN if ok else RED)def_hint(self, parent, text): tk.Label(parent, text=text, font=F_SMALL, bg=CARD, fg=TEXT_WEAK, wraplength=560, justify="left").pack(anchor="w", padx=16, pady=(2, 0))# ════════════════════════════════════════# Tab1: 字符图形验证码# ════════════════════════════════════════def_build_char_tab(self, parent): tk.Label(parent, text="字符图形验证码", font=F_TITLE, bg=CARD, fg=TEXT).pack( anchor="w", padx=16, pady=(14, 2)) self._hint(parent, "原理:随机字符渲染成图片,加入旋转、错位、干扰线和噪点,""机器难以 OCR 识别。点击图片可刷新。") self.char_canvas = tk.Canvas(parent, width=220, height=70, bg="#f1f5f9", highlightthickness=1, highlightbackground=BORDER) self.char_canvas.pack(anchor="w", padx=16, pady=10) self.char_canvas.bind("<Button-1>", lambda e: self._refresh_char_captcha()) row = tk.Frame(parent, bg=CARD) row.pack(fill="x", padx=16, pady=4) tk.Label(row, text="输入验证码:", font=F_BODY, bg=CARD, fg=TEXT).pack(side="left") self.char_input = tk.Entry(row, font=F_MONO, bg=INPUT_BG, fg=TEXT, relief="flat", bd=1, width=10) self.char_input.pack(side="left", padx=6, ipady=3) tk.Button(row, text="验证", font=F_SMALL, bg=ACCENT, fg="#ffffff", relief="flat", padx=12, command=self._verify_char).pack(side="left", padx=4) tk.Button(row, text="🔄 换一张", font=F_SMALL, bg=BTN_BG, fg=BTN_FG, relief="flat", padx=8, command=self._refresh_char_captcha).pack(side="left", padx=4) self.char_case_var = tk.BooleanVar(value=False) tk.Checkbutton(parent, text="区分大小写", variable=self.char_case_var, font=F_SMALL, bg=CARD, fg=TEXT_SEC, activebackground=CARD, selectcolor=CARD).pack(anchor="w", padx=16) self.char_result = self._result_label(parent) self._refresh_char_captcha()def_draw_noise(self, canvas, w, h, lines=6, dots=60):"""绘制干扰线与噪点"""for _ in range(lines): canvas.create_line(random.randint(0, w), random.randint(0, h), random.randint(0, w), random.randint(0, h), fill=random.choice(NOISE_COLORS), width=random.choice([1, 2]))for _ in range(dots): x, y = random.randint(0, w), random.randint(0, h) canvas.create_oval(x, y, x + 2, y + 2, fill=random.choice(NOISE_COLORS), outline="")def_refresh_char_captcha(self): self.char_canvas.delete("all") self.char_code = "".join(random.choices(CHAR_POOL, k=4)) w, h = 220, 70 self._draw_noise(self.char_canvas, w, h)# 逐字符绘制:随机位置偏移 + 随机角度 + 随机颜色for i, ch in enumerate(self.char_code): x = 35 + i * 50 + random.randint(-6, 6) y = 35 + random.randint(-10, 10) angle = random.randint(-30, 30) size = random.randint(22, 30) self.char_canvas.create_text( x, y, text=ch, font=("Consolas", size, "bold"), fill=random.choice(CHAR_COLORS), angle=angle) self.char_result.configure(text="") self.char_input.delete(0, "end")def_verify_char(self): user = self.char_input.get().strip()if self.char_case_var.get(): ok = user == self.char_codeelse: ok = user.lower() == self.char_code.lower() self._show_result(self.char_result, ok, fail_text=f"❌ 验证失败(正确答案: {self.char_code})")if ok: self.root.after(1500, self._refresh_char_captcha)# ════════════════════════════════════════# Tab2: 算术验证码# ════════════════════════════════════════def_build_math_tab(self, parent): tk.Label(parent, text="算术验证码", font=F_TITLE, bg=CARD, fg=TEXT).pack( anchor="w", padx=16, pady=(14, 2)) self._hint(parent, "原理:随机生成简单算式渲染成图片,用户需理解语义并计算,""比纯字符识别多一层门槛。") self.math_canvas = tk.Canvas(parent, width=240, height=70, bg="#f1f5f9", highlightthickness=1, highlightbackground=BORDER) self.math_canvas.pack(anchor="w", padx=16, pady=10) self.math_canvas.bind("<Button-1>", lambda e: self._refresh_math_captcha()) row = tk.Frame(parent, bg=CARD) row.pack(fill="x", padx=16, pady=4) tk.Label(row, text="计算结果:", font=F_BODY, bg=CARD, fg=TEXT).pack(side="left") self.math_input = tk.Entry(row, font=F_MONO, bg=INPUT_BG, fg=TEXT, relief="flat", bd=1, width=10) self.math_input.pack(side="left", padx=6, ipady=3) self.math_input.bind("<Return>", lambda e: self._verify_math()) tk.Button(row, text="验证", font=F_SMALL, bg=ACCENT, fg="#ffffff", relief="flat", padx=12, command=self._verify_math).pack(side="left", padx=4) tk.Button(row, text="🔄 换一题", font=F_SMALL, bg=BTN_BG, fg=BTN_FG, relief="flat", padx=8, command=self._refresh_math_captcha).pack(side="left", padx=4) self.math_result = self._result_label(parent) self._refresh_math_captcha()def_refresh_math_captcha(self): self.math_canvas.delete("all") op = random.choice(["+", "-", "×"])if op == "+": a, b = random.randint(1, 50), random.randint(1, 50) self.math_answer = a + belif op == "-": a, b = random.randint(10, 99), random.randint(1, 9) self.math_answer = a - belse: a, b = random.randint(2, 9), random.randint(2, 9) self.math_answer = a * b w, h = 240, 70 self._draw_noise(self.math_canvas, w, h, lines=5, dots=40) expr = f"{a}{op}{b} = ?"# 逐字符绘制并加随机扰动 x = 30for ch in expr: y = 35 + random.randint(-6, 6) angle = random.randint(-15, 15) self.math_canvas.create_text( x, y, text=ch, font=("Consolas", 24, "bold"), fill=random.choice(CHAR_COLORS), angle=angle) x += 24 self.math_result.configure(text="") self.math_input.delete(0, "end")def_verify_math(self): user = self.math_input.get().strip()try: ok = int(user) == self.math_answerexcept ValueError: ok = False self._show_result(self.math_result, ok, fail_text=f"❌ 验证失败(正确答案: {self.math_answer})")if ok: self.root.after(1500, self._refresh_math_captcha)# ════════════════════════════════════════# Tab3: 滑块拼图验证码# ════════════════════════════════════════ SLIDER_W, SLIDER_H = 480, 160 PIECE_SIZE = 44 TOLERANCE = 6# 允许误差(像素)def_build_slider_tab(self, parent): tk.Label(parent, text="滑块拼图验证码", font=F_TITLE, bg=CARD, fg=TEXT).pack( anchor="w", padx=16, pady=(14, 2)) self._hint(parent, "原理:拖动滑块使拼图块对齐背景缺口,服务端校验最终位置与""拖动轨迹(人手拖动有抖动,机器轨迹过于平滑)。") self.slider_canvas = tk.Canvas(parent, width=self.SLIDER_W, height=self.SLIDER_H, bg="#e0f2fe", highlightthickness=1, highlightbackground=BORDER) self.slider_canvas.pack(anchor="w", padx=16, pady=(10, 6))# 滑轨 self.track_canvas = tk.Canvas(parent, width=self.SLIDER_W, height=44, bg=INPUT_BG, highlightthickness=1, highlightbackground=BORDER) self.track_canvas.pack(anchor="w", padx=16, pady=(0, 6)) self.track_canvas.bind("<Button-1>", self._slider_press) self.track_canvas.bind("<B1-Motion>", self._slider_drag) self.track_canvas.bind("<ButtonRelease-1>", self._slider_release) row = tk.Frame(parent, bg=CARD) row.pack(fill="x", padx=16, pady=2) tk.Button(row, text="🔄 重置", font=F_SMALL, bg=BTN_BG, fg=BTN_FG, relief="flat", padx=8, command=self._reset_slider).pack(side="left") self.slider_info = tk.Label(row, text="", font=F_SMALL, bg=CARD, fg=TEXT_WEAK) self.slider_info.pack(side="left", padx=10) self.slider_result = self._result_label(parent) self._reset_slider()def_reset_slider(self): c = self.slider_canvas c.delete("all") ps = self.PIECE_SIZE# 背景装饰(随机圆点模拟图片纹理)for _ in range(40): x, y = random.randint(0, self.SLIDER_W), random.randint(0, self.SLIDER_H) r = random.randint(4, 16) color = random.choice(["#bae6fd", "#7dd3fc", "#a5f3fc", "#c7d2fe", "#ddd6fe"]) c.create_oval(x - r, y - r, x + r, y + r, fill=color, outline="")# 缺口位置(避开左侧起始区) self.gap_x = random.randint(ps + 100, self.SLIDER_W - ps - 20) self.gap_y = random.randint(20, self.SLIDER_H - ps - 20) c.create_rectangle(self.gap_x, self.gap_y, self.gap_x + ps, self.gap_y + ps, fill="#94a3b8", outline="#64748b", width=2, stipple="gray50")# 拼图块(起始在最左侧,与缺口同一水平线) self.piece_x = 10 self.piece = c.create_rectangle( self.piece_x, self.gap_y, self.piece_x + ps, self.gap_y + ps, fill=ACCENT, outline="#1d4ed8", width=2)# 滑轨 t = self.track_canvas t.delete("all") t.create_text(self.SLIDER_W // 2, 22, text="按住滑块,拖动完成拼图 →", font=F_SMALL, fill=TEXT_WEAK) self.handle_x = 10 self.handle = t.create_rectangle(self.handle_x, 4, self.handle_x + 40, 40, fill=ACCENT, outline="#1d4ed8", width=2) t.create_text(self.handle_x + 20, 22, text="⇒", font=("Segoe UI", 14, "bold"), fill="white", tags="arrow") self.dragging = False self.drag_track = [] # 记录拖动轨迹 (时间, x) self.slider_done = False self.slider_result.configure(text="") self.slider_info.configure(text=f"缺口位置 x={self.gap_x}(演示用,实际不可见)")def_slider_press(self, event):if self.slider_done:return# 只有点在滑块上才开始拖动if self.handle_x <= event.x <= self.handle_x + 40: self.dragging = True self.drag_offset = event.x - self.handle_x self.drag_track = [(time.time(), event.x)]def_slider_drag(self, event):ifnot self.dragging or self.slider_done:return ps = self.PIECE_SIZE new_x = event.x - self.drag_offset new_x = max(10, min(new_x, self.SLIDER_W - 50)) dx = new_x - self.handle_x self.handle_x = new_x self.track_canvas.move(self.handle, dx, 0) self.track_canvas.move("arrow", dx, 0)# 拼图块同步移动(滑轨行程映射到画布行程) ratio = (self.SLIDER_W - ps - 20) / (self.SLIDER_W - 60) self.piece_x = 10 + (self.handle_x - 10) * ratio coords = self.slider_canvas.coords(self.piece) self.slider_canvas.move(self.piece, self.piece_x - coords[0], 0) self.drag_track.append((time.time(), event.x))def_slider_release(self, event):ifnot self.dragging or self.slider_done:return self.dragging = False diff = abs(self.piece_x - self.gap_x) ok = diff <= self.TOLERANCE# 简易轨迹分析:拖动耗时 + 采样点数if len(self.drag_track) >= 2: duration = self.drag_track[-1][0] - self.drag_track[0][0] points = len(self.drag_track) track_info = f"耗时 {duration:.2f}s,采样 {points} 点"else: track_info = "无有效轨迹"if ok: self.slider_done = True self.slider_canvas.itemconfigure(self.piece, fill=GREEN, outline="#059669") self.slider_result.configure( text=f"✅ 验证通过(偏差 {diff:.0f}px,{track_info})", fg=GREEN)else: self.slider_result.configure( text=f"❌ 未对齐缺口(偏差 {diff:.0f}px,容差 {self.TOLERANCE}px),已重置", fg=RED) self.root.after(1200, self._reset_slider)# ════════════════════════════════════════# Tab4: 文字点选验证码# ════════════════════════════════════════ CLICK_W, CLICK_H = 480, 200 CLICK_R = 22# 点击判定半径def_build_click_tab(self, parent): tk.Label(parent, text="文字点选验证码", font=F_TITLE, bg=CARD, fg=TEXT).pack( anchor="w", padx=16, pady=(14, 2)) self._hint(parent, "原理:图片上随机放置多个文字,要求按指定顺序点击其中几个,""同时考验文字识别与语序理解。") self.click_tip = tk.Label(parent, text="", font=F_BODY, bg=CARD, fg=ACCENT) self.click_tip.pack(anchor="w", padx=16, pady=(6, 2)) self.click_canvas = tk.Canvas(parent, width=self.CLICK_W, height=self.CLICK_H, bg="#fef9c3", highlightthickness=1, highlightbackground=BORDER) self.click_canvas.pack(anchor="w", padx=16, pady=6) self.click_canvas.bind("<Button-1>", self._on_click_captcha) row = tk.Frame(parent, bg=CARD) row.pack(fill="x", padx=16, pady=2) tk.Button(row, text="🔄 换一张", font=F_SMALL, bg=BTN_BG, fg=BTN_FG, relief="flat", padx=8, command=self._reset_click_captcha).pack(side="left") self.click_result = self._result_label(parent) self._reset_click_captcha()def_reset_click_captcha(self): c = self.click_canvas c.delete("all") self._draw_noise(c, self.CLICK_W, self.CLICK_H, lines=4, dots=30)# 随机选 5 个不重复汉字,前 3 个为需点击目标 chars = random.sample(HANZI_POOL, 5) self.click_targets = chars[:3] self.click_positions = {} # 字 -> (x, y)# 随机摆放,简单避免重叠 placed = []for ch in chars:for _ in range(50): x = random.randint(40, self.CLICK_W - 40) y = random.randint(35, self.CLICK_H - 35)if all((x - px) ** 2 + (y - py) ** 2 > 70 ** 2for px, py in placed):break placed.append((x, y)) self.click_positions[ch] = (x, y) c.create_text(x, y, text=ch, font=("Microsoft YaHei", random.randint(18, 26), "bold"), fill=random.choice(CHAR_COLORS), angle=random.randint(-25, 25), tags=f"char_{ch}") self.clicked_seq = [] self.click_done = False self.click_tip.configure( text="请依次点击:" + " → ".join(f"「{t}」"for t in self.click_targets)) self.click_result.configure(text="")def_on_click_captcha(self, event):if self.click_done:return# 找最近的字 hit = Nonefor ch, (x, y) in self.click_positions.items():if (event.x - x) ** 2 + (event.y - y) ** 2 <= self.CLICK_R ** 2: hit = chbreakif hit isNoneor hit in self.clicked_seq:return self.clicked_seq.append(hit) idx = len(self.clicked_seq) x, y = self.click_positions[hit]# 标记序号 self.click_canvas.create_oval(x - 14, y - 14, x + 14, y + 14, outline=ACCENT, width=2) self.click_canvas.create_text(x + 20, y - 18, text=str(idx), font=("Microsoft YaHei", 10, "bold"), fill=ACCENT)if idx == len(self.click_targets): self.click_done = True ok = self.clicked_seq == self.click_targets seq = " → ".join(self.clicked_seq) self._show_result(self.click_result, ok, ok_text=f"✅ 验证通过({seq})", fail_text=f"❌ 顺序错误(你点的是 {seq}),已重置")ifnot ok: self.root.after(1500, self._reset_click_captcha)# ════════════════════════════════════════# Tab5: 短信验证码(模拟)# ════════════════════════════════════════ SMS_RESEND_SEC = 60# 重发冷却 SMS_VALID_SEC = 120# 有效期def_build_sms_tab(self, parent): tk.Label(parent, text="短信验证码(模拟)", font=F_TITLE, bg=CARD, fg=TEXT).pack( anchor="w", padx=16, pady=(14, 2)) self._hint(parent, "原理:服务端生成 6 位随机数字下发到手机,附带有效期与重发""冷却限制,验证「持有手机」这一事实。此处用弹出面板模拟收到的短信。") row = tk.Frame(parent, bg=CARD) row.pack(fill="x", padx=16, pady=(12, 4)) tk.Label(row, text="手机号:", font=F_BODY, bg=CARD, fg=TEXT).pack(side="left") self.phone_input = tk.Entry(row, font=F_MONO, bg=INPUT_BG, fg=TEXT, relief="flat", bd=1, width=14) self.phone_input.pack(side="left", padx=6, ipady=3) self.phone_input.insert(0, "13800001234") self.sms_send_btn = tk.Button(row, text="发送验证码", font=F_SMALL, bg=ACCENT, fg="#ffffff", relief="flat", padx=12, command=self._send_sms) self.sms_send_btn.pack(side="left", padx=4)# 模拟手机短信面板 sms_box = tk.Frame(parent, bg="#f0fdf4", bd=1, relief="solid") sms_box.pack(fill="x", padx=16, pady=8) tk.Label(sms_box, text="📱 模拟手机短信收件箱", font=F_SMALL, bg="#f0fdf4", fg=TEXT_SEC).pack(anchor="w", padx=10, pady=(6, 0)) self.sms_msg = tk.Label(sms_box, text="(暂无短信)", font=F_BODY, bg="#f0fdf4", fg=TEXT, wraplength=540, justify="left") self.sms_msg.pack(anchor="w", padx=10, pady=(2, 8)) row2 = tk.Frame(parent, bg=CARD) row2.pack(fill="x", padx=16, pady=4) tk.Label(row2, text="输入验证码:", font=F_BODY, bg=CARD, fg=TEXT).pack(side="left") self.sms_input = tk.Entry(row2, font=F_BIG_MONO, bg=INPUT_BG, fg=TEXT, relief="flat", bd=1, width=8, justify="center") self.sms_input.pack(side="left", padx=6, ipady=2) self.sms_input.bind("<Return>", lambda e: self._verify_sms()) tk.Button(row2, text="验证", font=F_SMALL, bg=GREEN, fg="#ffffff", relief="flat", padx=14, command=self._verify_sms).pack(side="left", padx=4) self.sms_status = tk.Label(parent, text="", font=F_SMALL, bg=CARD, fg=TEXT_WEAK) self.sms_status.pack(anchor="w", padx=16, pady=(4, 0)) self.sms_result = self._result_label(parent) self.sms_code = None self.sms_sent_time = 0 self.sms_countdown_job = Nonedef_send_sms(self): phone = self.phone_input.get().strip()ifnot (phone.isdigit() and len(phone) == 11and phone.startswith("1")): self.sms_result.configure(text="❌ 请输入 11 位有效手机号", fg=RED)return self.sms_code = "".join(random.choices(string.digits, k=6)) self.sms_sent_time = time.time() masked = phone[:3] + "****" + phone[7:] self.sms_msg.configure( text=f"【验证码演示】您的验证码是 {self.sms_code},"f"{self.SMS_VALID_SEC // 60} 分钟内有效,请勿泄露。(发送至 {masked})") self.sms_result.configure(text="") self.sms_input.delete(0, "end") self._sms_tick()def_sms_tick(self):"""重发倒计时 + 有效期状态刷新"""if self.sms_countdown_job: self.root.after_cancel(self.sms_countdown_job) self.sms_countdown_job = Noneif self.sms_code isNone:return elapsed = time.time() - self.sms_sent_time resend_left = int(self.SMS_RESEND_SEC - elapsed) valid_left = int(self.SMS_VALID_SEC - elapsed)if resend_left > 0: self.sms_send_btn.configure(text=f"重发({resend_left}s)", state="disabled", bg="#93c5fd")else: self.sms_send_btn.configure(text="发送验证码", state="normal", bg=ACCENT)if valid_left > 0: self.sms_status.configure(text=f"验证码剩余有效期: {valid_left}s") self.sms_countdown_job = self.root.after(1000, self._sms_tick)else: self.sms_status.configure(text="验证码已过期,请重新发送") self.sms_code = None self.sms_send_btn.configure(text="发送验证码", state="normal", bg=ACCENT)def_verify_sms(self): user = self.sms_input.get().strip()if self.sms_code isNone: self.sms_result.configure(text="❌ 请先发送验证码(或已过期)", fg=RED)returnif time.time() - self.sms_sent_time > self.SMS_VALID_SEC: self.sms_result.configure(text="❌ 验证码已过期,请重新发送", fg=RED) self.sms_code = Nonereturn ok = user == self.sms_code self._show_result(self.sms_result, ok, fail_text="❌ 验证码错误")if ok: self.sms_code = Noneif self.sms_countdown_job: self.root.after_cancel(self.sms_countdown_job) self.sms_countdown_job = None self.sms_status.configure(text="") self.sms_send_btn.configure(text="发送验证码", state="normal", bg=ACCENT)defrun(self): self.root.mainloop()if __name__ == "__main__": app = CaptchaDemo() app.run()文档结束 · 本项目仅用于验证码原理演示与学习