当前位置:首页>python>PythonEnglish Typing Tutor - 英语指法打字练习系统

PythonEnglish Typing Tutor - 英语指法打字练习系统

  • 2026-09-08 20:10:16
PythonEnglish Typing Tutor - 英语指法打字练习系统

English Typing Tutor - 英语指法打字练习系统

项目简介

English Typing Tutor 是一款面向中小学生和英语初学者的英语指法打字练习系统。本项目将标准盲打指法教学与单词背诵相结合,帮助用户在练习打字的同时记忆英语单词。系统采用纯前端技术栈实现,无需后端服务器支持,所有数据存储在浏览器本地,完全离线可用,不收集任何用户隐私数据。

本系统主打轻量化、无广告、教育向设计风格,适合课堂教学辅助和家庭自学使用。


技术架构

技术栈

层级
技术选型
说明
结构层
HTML5
语义化页面结构
样式层
CSS3
纯 CSS 实现,无第三方 UI 框架依赖
逻辑层
Vanilla JavaScript (ES6+)
模块化设计,零依赖
存储层
localStorage
浏览器本地持久化
OCR支持
pdf.js (可选 CDN)
PDF 文本提取

项目结构

english-typing-tutor/
├── index.html              # 主页面入口
├── README.md               # 项目说明文档
├── css/
│   └── style.css           # 全局样式表
└── js/
    ├── app.js              # 主应用入口,事件绑定与初始化
    ├── keyboard.js         # 虚拟键盘渲染与指法映射
    ├── practice.js         # 练习流程控制与输入判定逻辑
    ├── wordbank.js         # 词库管理(导入/编辑/存储/统计)
    └── stats.js            # 实时数据统计与面板更新

模块职责划分

  • wordbank.js:词库的增删改查、JSON/PDF 导入、易错词管理、练习记录、localStorage 持久化
  • keyboard.js:QWERTY 虚拟键盘渲染、手指分区配色、按键高亮、指法提示文本生成
  • practice.js:练习模式状态机、输入字符判定、单词进度推进、组完成判定、音效播放
  • stats.js:正确率计算、WPM 速度统计、进度数据汇总、面板 DOM 更新
  • app.js:各模块初始化调度、DOM 事件监听绑定、文件导入处理、弹窗控制

功能模块详解

一、词库管理模块

词库是整个系统的数据基础,支持多种方式构建和维护:

1. 内置默认词库

系统预置 30 个基础英语单词(含中文释义),覆盖 A-Z 字母开头的常用词,确保用户开箱即用无需额外导入。

2. PDF 课文导入

支持批量上传 PDF 格式的英语课文文件,系统自动从中提取所有英文单词(2个字母及以上),自动去重后加入词库。如浏览器引入了 pdf.js 库,则使用专业 PDF 解析能力;否则回退为基础文本提取模式。

3. JSON 词库导入

支持导入标准 JSON 格式的自定义词库文件,格式为:

[
  {"en": "apple", "zh": "苹果"},
  {"en": "banana", "zh": "香蕉"}
]

导入时自动与已有词库合并去重。

4. 手动编辑

通过弹窗编辑器,用户可以逐行编辑词库内容,格式为 英文单词|中文释义,支持批量增删改操作。

5. 数据统计

侧边栏实时展示三类核心数据:总词数、已练习词数、错过词数,帮助用户直观了解学习进度。


二、练习模式模块

系统提供四种差异化的练习标签,覆盖不同学习场景:

模式
说明
适用场景
课文词模式
使用导入的课文词库顺序练习
配合教材同步学习
基准键指法
使用 home row 字母组成的单词练习
新手指法入门训练
易错词专项
仅练习之前输错过的单词
针对性巩固薄弱词汇
中文默写
隐藏英文,仅显示中文释义要求默写
深度记忆检测

用户可通过滑块自定义每组练习的单词数量(5-30个),灵活控制单次练习强度。


三、实时数据统计面板

顶部统计栏实时展示五项核心指标:

  1. 打字正确率:正确按键次数 / 总按键次数 × 100%
  2. 打字速度 WPM:每分钟完成的单词数(Words Per Minute)
  3. 本组进度:当前组内已完成单词数 / 本组总单词数
  4. 总词库进度:本次会话已练习总数 / 词库总词数
  5. 默写完成进度:默写模式下的完成情况

数据在每次按键后即时更新,为用户提供实时的学习反馈。


四、打字交互主界面

核心交互流程:

  1. 界面上方显示当前单词的中文释义(默写模式仅显示中文)
  2. 居中放大展示待输入的英文单词,每个字母独立着色显示输入状态
  3. 下方单行输入框实时接收键盘输入
  4. 输入正确的字母标记为绿色,自动推进到下一个字母
  5. 输入错误的字母触发红色闪烁提示,不推进位置,该词记入易错词库
  6. 整个单词输入完成后自动跳转下一词
  7. 一组单词全部完成后展示正确率和速度汇总

输入规则:

  • 仅接受英文字母输入,自动屏蔽中文输入法干扰(通过 composition 事件拦截)
  • 大小写开关开启后严格匹配,关闭则忽略大小写差异
  • 支持开启/关闭按键音效反馈

五、可视化指法教学虚拟键盘

系统底部渲染完整的三行 QWERTY 标准键盘加空格键,采用手指分区马卡龙低饱和度配色方案:

颜色
对应手指
负责按键区域
粉色 (fce4ec)
小指
Q A Z / P
黄色 (fff9c4)
无名指
W S X / O L
青绿色 (e0f7fa)
中指
E D C / I K
绿色 (e8f5e9)
食指
R F V T G B / Y H N U J M
灰色 (f5f5f5)
拇指
空格键

实时高亮机制:每当用户需要输入下一个字母时,虚拟键盘上对应的按键会放大并添加阴影效果高亮显示,同时文字区域提示该字母应使用哪只手的哪个手指按下,强制规范盲打指法习惯。


六、辅助功能

  • 练习记录清空:一键清除所有练习进度和易错词记录,重新开始
  • 词库批量编辑:弹窗式文本编辑器,支持快速修改所有单词和释义
  • 数据本地持久化:词库、易错词、已练习词均自动保存到浏览器 localStorage
  • 键盘快捷键:按 Enter 键可快速开始练习或切换下一组

UI 设计规范

本项目严格遵循以下视觉约束:

  • 配色方案:纯白底色 + 浅灰分割线 + 浅蓝渐变主按钮 + 马卡龙键盘配色
  • 布局结构:左右分栏,左侧 280px 配置面板,右侧弹性练习主区域
  • 卡片设计:12px 圆角 + 轻微阴影(0 2px 8px rgba(0,0,0,0.04))
  • 字体方案:系统字体栈,优先使用苹方/微软雅黑,确保中英文清晰显示
  • 交互反馈:按钮悬停微上浮、输入错误抖动动画、正确输入绿色渐变
  • 响应式适配:900px 以下断点自动切换为上下布局

使用指南

快速开始

  1. 直接在浏览器中打开 index.html 文件即可运行
  2. 点击左侧「开始练习」按钮,系统加载默认词库开始练习
  3. 在输入框中输入显示的英文单词,观察虚拟键盘的指法提示
  4. 一组完成后查看正确率和速度,点击「下一组」继续练习

导入自定义词库

方法一:JSON 导入

准备 JSON 文件(数组格式,每项包含 en 和 zh 字段),点击「导入 JSON 词库」按钮选择文件即可。

方法二:PDF 导入

直接上传英语课文 PDF 文件,系统自动提取其中所有英文单词。提取后的单词释义默认为「待补充释义」,可通过编辑器手动补充。

如需完整 PDF 解析能力,请在 index.html 的 <head> 标签中加入:

<scriptsrc="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>

方法三:手动编辑

点击「编辑词库」按钮,在弹窗中按 单词|释义 格式逐行编辑,保存即生效。

模式选择建议

学习阶段
推荐模式
建议每组词数
零基础入门
基准键指法
5-8 个
日常练习
课文词模式
10-15 个
考前复习
中文默写
10-20 个
薄弱巩固
易错词专项
全部

桌面客户端打包

本项目为纯前端应用,可通过以下方式打包为 Windows 桌面客户端:

方案一:Electron 打包

npm init -y
npm install electron --save-dev
# 配置 main.js 加载 index.html
npx electron-packager . EnglishTypingTutor --platform=win32 --arch=x64

方案二:Tauri 打包(推荐,体积更小)

npm create tauri-app
# 将项目文件放入 src 目录
npm run tauri build

方案三:PWA 模式

添加 manifest.json 和 Service Worker 即可支持浏览器安装为桌面应用。


数据存储说明

所有数据均存储在浏览器 localStorage 中,键名如下:

存储键
内容
格式
typing_tutor_wordbank
完整词库
JSON 数组
typing_tutor_errors
易错词列表
JSON 数组(单词字符串)
typing_tutor_practiced
已练习词列表
JSON 数组(单词字符串)

清空浏览器缓存或点击「清空练习记录」按钮可重置练习数据(词库保留)。


浏览器兼容性

浏览器
最低版本
支持状态
Chrome
60+
✅ 完全支持
Firefox
55+
✅ 完全支持
Edge
79+
✅ 完全支持
Safari
12+
✅ 完全支持
IE
-
❌ 不支持

后续扩展方向

  • [ ] 添加 Service Worker 实现完整 PWA 离线缓存
  • [ ] 集成 Tesseract.js 实现浏览器端 OCR 识别图片型 PDF
  • [ ] 添加学习日历和每日打卡统计
  • [ ] 支持多用户切换(基于 localStorage 命名空间隔离)
  • [ ] 添加单词发音功能(Web Speech API)
  • [ ] 支持自定义键盘配色主题
  • [ ] 添加打字速度历史趋势图表(Canvas/SVG)
  • [ ] 支持导出学习报告为 PDF

开源协议

本项目仅供学习和教育用途,代码完全开源,可自由修改和分发。


联系与反馈

如有功能建议或使用问题,欢迎提交 Issue 或 Pull Request。

"""英语指法打字练习系统 - Python GUI 版本基于 tkinter 实现,完整离线桌面客户端"""import tkinter as tkfrom tkinter import ttk, filedialog, messagebox, simpledialogimport jsonimport osimport timeimport reimport threading===== 数据存储路径 =====DATA_DIR = os.path.join(os.path.expanduser("~"), ".english_typing_tutor")WORDBANK_FILE = os.path.join(DATA_DIR, "wordbank.json")ERRORS_FILE = os.path.join(DATA_DIR, "errors.json")PRACTICED_FILE = os.path.join(DATA_DIR, "practiced.json")os.makedirs(DATA_DIR, exist_ok=True)===== 默认词库 =====DEFAULT_WORDS = [{"en": "apple", "zh": "苹果"}, {"en": "banana", "zh": "香蕉"},{"en": "cat", "zh": "猫"}, {"en": "dog", "zh": "狗"},{"en": "elephant", "zh": "大象"}, {"en": "flower", "zh": "花"},{"en": "green", "zh": "绿色"}, {"en": "happy", "zh": "快乐的"},{"en": "ice", "zh": "冰"}, {"en": "jump", "zh": "跳"},{"en": "king", "zh": "国王"}, {"en": "lion", "zh": "狮子"},{"en": "mother", "zh": "母亲"}, {"en": "night", "zh": "夜晚"},{"en": "orange", "zh": "橙子"}, {"en": "pencil", "zh": "铅笔"},{"en": "queen", "zh": "女王"}, {"en": "rabbit", "zh": "兔子"},{"en": "school", "zh": "学校"}, {"en": "teacher", "zh": "老师"},{"en": "umbrella", "zh": "雨伞"}, {"en": "violet", "zh": "紫罗兰"},{"en": "water", "zh": "水"}, {"en": "yellow", "zh": "黄色"},{"en": "zoo", "zh": "动物园"}, {"en": "book", "zh": "书"},{"en": "chair", "zh": "椅子"}, {"en": "door", "zh": "门"},{"en": "friend", "zh": "朋友"}, {"en": "garden", "zh": "花园"},]HOMEROW_WORDS = [{"en": "sad", "zh": "悲伤的"}, {"en": "had", "zh": "有(过去式)"},{"en": "ask", "zh": "问"}, {"en": "fall", "zh": "落下"},{"en": "half", "zh": "一半"}, {"en": "hall", "zh": "大厅"},{"en": "shall", "zh": "将"}, {"en": "flash", "zh": "闪光"},{"en": "glass", "zh": "玻璃"}, {"en": "glad", "zh": "高兴的"},{"en": "flag", "zh": "旗帜"}, {"en": "salad", "zh": "沙拉"},{"en": "dad", "zh": "爸爸"}, {"en": "add", "zh": "添加"},{"en": "all", "zh": "所有"}, {"en": "gas", "zh": "气体"},{"en": "lass", "zh": "少女"}, {"en": "lad", "zh": "男孩"},{"en": "gal", "zh": "女孩"}, {"en": "dash", "zh": "冲刺"},]===== 手指分区映射 =====FINGER_MAP = {'q': 'pinky', 'a': 'pinky', 'z': 'pinky','w': 'ring', 's': 'ring', 'x': 'ring','e': 'middle', 'd': 'middle', 'c': 'middle','r': 'index', 'f': 'index', 'v': 'index','t': 'index', 'g': 'index', 'b': 'index','y': 'index', 'h': 'index', 'n': 'index','u': 'index', 'j': 'index', 'm': 'index','i': 'middle', 'k': 'middle','o': 'ring', 'l': 'ring','p': 'pinky',' ': 'thumb'}FINGER_NAMES = {'pinky': '小指', 'ring': '无名指', 'middle': '中指','index': '食指', 'thumb': '拇指'}FINGER_HAND = {'q': '左手', 'w': '左手', 'e': '左手', 'r': '左手', 't': '左手','a': '左手', 's': '左手', 'd': '左手', 'f': '左手', 'g': '左手','z': '左手', 'x': '左手', 'c': '左手', 'v': '左手', 'b': '左手','y': '右手', 'u': '右手', 'i': '右手', 'o': '右手', 'p': '右手','h': '右手', 'j': '右手', 'k': '右手', 'l': '右手','n': '右手', 'm': '右手', ' ': '任意'}手指分区颜色(马卡龙低饱和度配色)FINGER_COLORS = {'pinky': '#fce4ec','ring': '#fff9c4','middle': '#e0f7fa','index': '#e8f5e9','thumb': '#f0f0f0'}FINGER_ACTIVE_COLORS = {'pinky': '#f48fb1','ring': '#fff176','middle': '#4dd0e1','index': '#66bb6a','thumb': '#bdbdbd'}===== 词库管理类 =====class WordBankManager:def init(self):self.words = []self.error_words = set()self.practiced_words = set()self.load()def load(self):    if os.path.exists(WORDBANK_FILE):        with open(WORDBANK_FILE, 'r', encoding='utf-8') as f:            self.words = json.load(f)    else:        self.words = list(DEFAULT_WORDS)        self.save()    if os.path.exists(ERRORS_FILE):        with open(ERRORS_FILE, 'r', encoding='utf-8') as f:            self.error_words = set(json.load(f))    if os.path.exists(PRACTICED_FILE):        with open(PRACTICED_FILE, 'r', encoding='utf-8') as f:            self.practiced_words = set(json.load(f))def save(self):    with open(WORDBANK_FILE, 'w', encoding='utf-8') as f:        json.dump(self.words, f, ensure_ascii=False, indent=2)def save_errors(self):    with open(ERRORS_FILE, 'w', encoding='utf-8') as f:        json.dump(list(self.error_words), f, ensure_ascii=False)def save_practiced(self):    with open(PRACTICED_FILE, 'w', encoding='utf-8') as f:        json.dump(list(self.practiced_words), f, ensure_ascii=False)def get_all(self):    return self.wordsdef get_homerow(self):    return HOMEROW_WORDSdef get_errors(self):    return [w for w in self.words if w['en'] in self.error_words]def add_error(self, word):    self.error_words.add(word)    self.save_errors()def mark_practiced(self, word):    self.practiced_words.add(word)    self.save_practiced()def get_stats(self):    return {        'total': len(self.words),        'practiced': len(self.practiced_words),        'errors': len(self.error_words)    }def import_json(self, data):    if isinstance(data, list):        new_words = [w for w in data if 'en' in w and 'zh' in w]        self.words.extend(new_words)        seen = set()        unique = []        for w in self.words:            key = w['en'].lower()            if key not in seen:                seen.add(key)                unique.append(w)        self.words = unique        self.save()def import_from_text(self, text):    english_words = re.findall(r'\b[a-zA-Z]{2,}\b', text)    unique = list(set(w.lower() for w in english_words))    existing = {w['en'].lower() for w in self.words}    added = 0    for w in unique:        if w not in existing:            self.words.append({'en': w, 'zh': '待补充释义'})            added += 1    self.save()    return addeddef set_words(self, new_words):    self.words = new_words    self.save()def clear_records(self):    self.error_words.clear()    self.practiced_words.clear()    if os.path.exists(ERRORS_FILE):        os.remove(ERRORS_FILE)    if os.path.exists(PRACTICED_FILE):        os.remove(PRACTICED_FILE)===== 主应用窗口 =====class TypingTutorApp:def init(self, root):self.root = rootself.root.title("英语指法打字练习系统")self.root.geometry("1200x750")self.root.configure(bg='#f8f9fa')self.root.minsize(1000, 650)    # 数据    self.word_bank = WordBankManager()    self.current_mode = 'textbook'    self.current_words = []    self.current_index = 0    self.current_letter_index = 0    self.group_size = 10    self.is_active = False    self.case_sensitive = False    self.show_translation = True    self.sound_enabled = False    # 统计    self.correct_count = 0    self.total_keystrokes = 0    self.words_completed = 0    self.start_time = None    self.total_practiced_session = 0    self.dictation_completed = 0    # 键盘按键引用    self.key_labels = {}    self.build_ui()    self.update_word_stats()def build_ui(self):    # 顶部统计栏    self.build_stats_bar()    # 主区域    main_frame = tk.Frame(self.root, bg='#f8f9fa')    main_frame.pack(fill=tk.BOTH, expand=True, padx=12, pady=(0, 12))    # 左侧面板    self.build_sidebar(main_frame)    # 右侧练习区    self.build_practice_area(main_frame)def build_stats_bar(self):    bar = tk.Frame(self.root, bg='white', relief='flat', bd=0)    bar.pack(fill=tk.X, padx=12, pady=(12, 8))    # 外部圆角效果通过 padding 模拟    inner = tk.Frame(bar, bg='white', padx=20, pady=12)    inner.pack(fill=tk.X)    stats_data = [        ("正确率", "accuracy", "0%"),        ("速度 WPM", "wpm", "0"),        ("本组进度", "group_prog", "0/0"),        ("总词库进度", "total_prog", "0/0"),        ("默写完成", "dict_prog", "0/0"),    ]    self.stat_vars = {}    for i, (label, key, default) in enumerate(stats_data):        frame = tk.Frame(inner, bg='white')        frame.pack(side=tk.LEFT, expand=True)        tk.Label(frame, text=label, font=('Microsoft YaHei', 9),                 fg='#888', bg='white').pack()        var = tk.StringVar(value=default)        self.stat_vars[key] = var        tk.Label(frame, textvariable=var, font=('Microsoft YaHei', 16, 'bold'),                 fg='#4a9eff', bg='white').pack()def build_sidebar(self, parent):    sidebar = tk.Frame(parent, bg='#f8f9fa', width=260)    sidebar.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 12))    sidebar.pack_propagate(False)    # 词库管理卡片    card1 = self.create_card(sidebar, "词库管理")    tk.Button(card1, text="导入 PDF 课文", command=self.import_pdf,              bg='#74b9ff', fg='white', relief='flat', font=('Microsoft YaHei', 10),              cursor='hand2').pack(fill=tk.X, pady=(0, 4))    tk.Button(card1, text="导入 JSON 词库", command=self.import_json,              bg='#f0f0f0', fg='#555', relief='flat', font=('Microsoft YaHei', 10),              cursor='hand2').pack(fill=tk.X, pady=(0, 8))    stats_frame = tk.Frame(card1, bg='#f9f9f9', padx=8, pady=6)    stats_frame.pack(fill=tk.X, pady=(0, 6))    self.lbl_total = tk.Label(stats_frame, text="总词: 0", font=('Microsoft YaHei', 9),                              bg='#f9f9f9', fg='#666')    self.lbl_total.pack(side=tk.LEFT, expand=True)    self.lbl_practiced = tk.Label(stats_frame, text="已练: 0", font=('Microsoft YaHei', 9),                                  bg='#f9f9f9', fg='#666')    self.lbl_practiced.pack(side=tk.LEFT, expand=True)    self.lbl_errors = tk.Label(stats_frame, text="错过: 0", font=('Microsoft YaHei', 9),                               bg='#f9f9f9', fg='#666')    self.lbl_errors.pack(side=tk.LEFT, expand=True)    tk.Button(card1, text="编辑词库", command=self.edit_words,              bg='#f5f5f5', fg='#666', relief='flat', font=('Microsoft YaHei', 9),              cursor='hand2').pack(fill=tk.X)    # 练习模式卡片    card2 = self.create_card(sidebar, "练习模式")    modes_frame = tk.Frame(card2, bg='white')    modes_frame.pack(fill=tk.X)    self.mode_buttons = {}    modes = [("课文词", "textbook"), ("基准键", "homerow"),             ("易错词", "errors"), ("默写", "dictation")]    for i, (text, mode) in enumerate(modes):        btn = tk.Button(modes_frame, text=text, font=('Microsoft YaHei', 9),                        relief='flat', cursor='hand2',                        command=lambda m=mode: self.set_mode(m))        btn.grid(row=i // 2, column=i % 2, sticky='ew', padx=2, pady=2)        modes_frame.columnconfigure(i % 2, weight=1)        self.mode_buttons[mode] = btn    self.update_mode_buttons()    # 设置卡片    card3 = self.create_card(sidebar, "设置")    # 每组单词数    tk.Label(card3, text="每组单词数", font=('Microsoft YaHei', 9),             bg='white', fg='#666').pack(anchor='w')    slider_frame = tk.Frame(card3, bg='white')    slider_frame.pack(fill=tk.X, pady=(0, 8))    self.group_size_var = tk.IntVar(value=10)    self.group_slider = tk.Scale(slider_frame, from_=5, to=30, orient=tk.HORIZONTAL,                                 variable=self.group_size_var, command=self.on_slider_change,                                 bg='white', highlightthickness=0, troughcolor='#e0e0e0')    self.group_slider.pack(fill=tk.X)    # 开关选项    self.sound_var = tk.BooleanVar(value=False)    tk.Checkbutton(card3, text="按键音效", variable=self.sound_var,                   command=self.on_settings_change, bg='white',                   font=('Microsoft YaHei', 9)).pack(anchor='w')    self.case_var = tk.BooleanVar(value=False)    tk.Checkbutton(card3, text="区分大小写", variable=self.case_var,                   command=self.on_settings_change, bg='white',                   font=('Microsoft YaHei', 9)).pack(anchor='w')    self.trans_var = tk.BooleanVar(value=True)    tk.Checkbutton(card3, text="显示中文翻译", variable=self.trans_var,                   command=self.on_settings_change, bg='white',                   font=('Microsoft YaHei', 9)).pack(anchor='w')    # 清空记录    card4 = self.create_card(sidebar, "")    tk.Button(card4, text="清空练习记录", command=self.clear_records,              bg='#ffe0e0', fg='#e74c3c', relief='flat', font=('Microsoft YaHei', 10),              cursor='hand2').pack(fill=tk.X)def create_card(self, parent, title):    card = tk.Frame(parent, bg='white', padx=12, pady=10, relief='flat', bd=0)    card.pack(fill=tk.X, pady=(0, 8))    if title:        tk.Label(card, text=title, font=('Microsoft YaHei', 11, 'bold'),                 bg='white', fg='#555').pack(anchor='w', pady=(0, 8))    return carddef build_practice_area(self, parent):    area = tk.Frame(parent, bg='white', padx=24, pady=20)    area.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)    # 中文释义    self.meaning_var = tk.StringVar(value="点击「开始练习」开始")    tk.Label(area, textvariable=self.meaning_var, font=('Microsoft YaHei', 14),             fg='#888', bg='white').pack(pady=(10, 8))    # 单词展示区域    self.word_frame = tk.Frame(area, bg='white', height=80)    self.word_frame.pack(fill=tk.X, pady=(0, 16))    self.word_display_var = tk.StringVar(value="Ready")    self.word_label = tk.Label(self.word_frame, textvariable=self.word_display_var,                               font=('Consolas', 36, 'bold'), fg='#ddd', bg='white')    self.word_label.pack()    # 字母逐个显示的 frame    self.letters_frame = tk.Frame(area, bg='white')    self.letters_frame.pack(pady=(0, 16))    self.letter_labels = []    # 输入框    input_frame = tk.Frame(area, bg='white')    input_frame.pack(pady=(0, 12))    self.input_var = tk.StringVar()    self.input_entry = tk.Entry(input_frame, textvariable=self.input_var,                                font=('Consolas', 18), width=30, justify='center',                                relief='flat', bd=2, highlightthickness=2,                                highlightcolor='#74b9ff', highlightbackground='#e0e0e0')    self.input_entry.pack()    self.input_entry.bind('<KeyRelease>', self.on_key_release)    self.input_entry.bind('<Key>', self.on_key_press)    # 指法提示    self.hint_var = tk.StringVar(value="等待输入...")    tk.Label(area, textvariable=self.hint_var, font=('Microsoft YaHei', 11),             fg='#888', bg='white').pack(pady=(0, 16))    # 操作按钮    btn_frame = tk.Frame(area, bg='white')    btn_frame.pack(pady=(0, 20))    self.btn_start = tk.Button(btn_frame, text="开始练习", command=self.start_practice,                               bg='#74b9ff', fg='white', relief='flat',                               font=('Microsoft YaHei', 11), padx=20, pady=6, cursor='hand2')    self.btn_start.pack(side=tk.LEFT, padx=4)    self.btn_next = tk.Button(btn_frame, text="下一组", command=self.next_group,                              bg='#f0f0f0', fg='#555', relief='flat',                              font=('Microsoft YaHei', 11), padx=20, pady=6,                              state=tk.DISABLED, cursor='hand2')    self.btn_next.pack(side=tk.LEFT, padx=4)    # 虚拟键盘    self.keyboard_frame = tk.Frame(area, bg='white')    self.keyboard_frame.pack(fill=tk.X, pady=(10, 0))    self.build_keyboard()def build_keyboard(self):    rows = [        ['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],        ['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],        ['z', 'x', 'c', 'v', 'b', 'n', 'm']    ]    for row_keys in rows:        row_frame = tk.Frame(self.keyboard_frame, bg='white')        row_frame.pack(pady=2)        for key in row_keys:            finger = FINGER_MAP.get(key, 'thumb')            color = FINGER_COLORS[finger]            lbl = tk.Label(row_frame, text=key.upper(), width=4, height=2,                           bg=color, font=('Consolas', 11, 'bold'), fg='#555',                           relief='flat', bd=1)            lbl.pack(side=tk.LEFT, padx=2)            self.key_labels[key] = lbl    # 空格键    space_frame = tk.Frame(self.keyboard_frame, bg='white')    space_frame.pack(pady=2)    space_lbl = tk.Label(space_frame, text="SPACE", width=30, height=2,                         bg=FINGER_COLORS['thumb'], font=('Consolas', 11),                         fg='#888', relief='flat', bd=1)    space_lbl.pack()    self.key_labels[' '] = space_lbldef highlight_key(self, letter):    """高亮指定按键"""    # 先清除所有高亮    for key, lbl in self.key_labels.items():        finger = FINGER_MAP.get(key, 'thumb')        lbl.configure(bg=FINGER_COLORS[finger], fg='#555',                      font=('Consolas', 11, 'bold'))    # 高亮目标键    key = letter.lower()    if key in self.key_labels:        finger = FINGER_MAP.get(key, 'thumb')        self.key_labels[key].configure(            bg=FINGER_ACTIVE_COLORS[finger], fg='white',            font=('Consolas', 12, 'bold'))def clear_highlight(self):    for key, lbl in self.key_labels.items():        finger = FINGER_MAP.get(key, 'thumb')        lbl.configure(bg=FINGER_COLORS[finger], fg='#555',                      font=('Consolas', 11, 'bold'))def get_finger_hint(self, letter):    key = letter.lower()    finger = FINGER_MAP.get(key, '')    hand = FINGER_HAND.get(key, '')    if not finger:        return ''    finger_name = FINGER_NAMES.get(finger, '')    return f"{hand}{finger_name} → {letter.upper()} | 手腕放松,先准后快"# ===== 练习逻辑 =====def start_practice(self):    self.correct_count = 0    self.total_keystrokes = 0    self.words_completed = 0    self.start_time = None    self.current_index = 0    self.current_letter_index = 0    self.total_practiced_session = 0    self.dictation_completed = 0    # 获取当前模式词库    if self.current_mode == 'textbook':        self.current_words = self.word_bank.get_all()[:self.group_size]    elif self.current_mode == 'homerow':        self.current_words = self.word_bank.get_homerow()[:self.group_size]    elif self.current_mode == 'errors':        self.current_words = self.word_bank.get_errors()[:self.group_size]        if not self.current_words:            messagebox.showinfo("提示", "暂无易错词,先练习其他模式吧!")            return    elif self.current_mode == 'dictation':        self.current_words = self.word_bank.get_all()[:self.group_size]    self.is_active = True    self.input_entry.configure(state=tk.NORMAL)    self.input_entry.focus_set()    self.input_var.set('')    self.btn_next.configure(state=tk.DISABLED)    self.show_current_word()    self.update_stats_display()def next_group(self):    self.correct_count = 0    self.total_keystrokes = 0    self.words_completed = 0    self.start_time = None    self.current_index = 0    self.current_letter_index = 0    all_words = self.word_bank.get_homerow() if self.current_mode == 'homerow' \        else self.word_bank.get_all()    offset = self.total_practiced_session    self.current_words = all_words[offset:offset + self.group_size]    if not self.current_words:        self.total_practiced_session = 0        self.current_words = all_words[:self.group_size]    self.is_active = True    self.input_entry.configure(state=tk.NORMAL)    self.input_entry.focus_set()    self.input_var.set('')    self.btn_next.configure(state=tk.DISABLED)    self.show_current_word()    self.update_stats_display()def show_current_word(self):    if self.current_index >= len(self.current_words):        return    word = self.current_words[self.current_index]    # 显示中文释义    if self.current_mode == 'dictation' or self.show_translation:        self.meaning_var.set(word['zh'])    else:        self.meaning_var.set('')    # 显示单词(逐字母)    self.display_word_letters(word['en'])    # 指法提示    first_letter = word['en'][0]    self.hint_var.set(self.get_finger_hint(first_letter))    self.highlight_key(first_letter)def display_word_letters(self, word):    # 清除旧的字母显示    for lbl in self.letter_labels:        lbl.destroy()    self.letter_labels.clear()    # 隐藏 word_label    self.word_label.pack_forget()    # 如果是默写模式,显示提示文字    if self.current_mode == 'dictation':        self.word_display_var.set("请根据中文释义输入英文单词")        self.word_label.configure(font=('Microsoft YaHei', 14), fg='#ccc')        self.word_label.pack()        return    # 显示逐字母    for widget in self.letters_frame.winfo_children():        widget.destroy()    self.letter_labels = []    for i, ch in enumerate(word):        if i == 0:            fg = '#0984e3'        else:            fg = '#ccc'        lbl = tk.Label(self.letters_frame, text=ch, font=('Consolas', 36, 'bold'),                       fg=fg, bg='white')        lbl.pack(side=tk.LEFT, padx=1)        self.letter_labels.append(lbl)def update_letter_display(self):    word = self.current_words[self.current_index]['en']    for i, lbl in enumerate(self.letter_labels):        if i < self.current_letter_index:            lbl.configure(fg='#27ae60')  # 正确-绿色        elif i == self.current_letter_index:            lbl.configure(fg='#0984e3')  # 当前-蓝色        else:            lbl.configure(fg='#ccc')  # 待输入-灰色def on_key_press(self, event):    """按键按下事件 - 过滤非英文输入"""    if not self.is_active:        return 'break'    char = event.char    # 允许退格和删除    if event.keysym in ('BackSpace', 'Delete'):        return 'break'  # 不允许回退    # 只允许英文字母    if char and not char.isalpha():        return 'break'    return Nonedef on_key_release(self, event):    """按键释放事件 - 处理输入逻辑"""    if not self.is_active:        return    char = event.char    if not char or not char.isalpha():        self.input_var.set('')        return    if self.start_time is None:        self.start_time = time.time()    word = self.current_words[self.current_index]    target_word = word['en']    target_char = target_word[self.current_letter_index]    # 判断正确性    if self.case_sensitive:        is_correct = char == target_char    else:        is_correct = char.lower() == target_char.lower()    self.total_keystrokes += 1    if is_correct:        self.correct_count += 1        self.current_letter_index += 1        # 播放正确音效        if self.sound_enabled:            self.play_beep(True)        # 更新字母显示        if self.current_mode != 'dictation':            self.update_letter_display()        # 指法提示 - 下一个字母        if self.current_letter_index < len(target_word):            next_letter = target_word[self.current_letter_index]            self.hint_var.set(self.get_finger_hint(next_letter))            self.highlight_key(next_letter)        # 单词完成        if self.current_letter_index >= len(target_word):            self.word_complete()    else:        # 错误处理        if self.sound_enabled:            self.play_beep(False)        self.word_bank.add_error(word['en'])        self.hint_var.set(            f"✗ 正确应为 \"{target_char.upper()}\" - {self.get_finger_hint(target_char)}")        # 输入框闪红(简单实现)        self.input_entry.configure(highlightbackground='#e74c3c', highlightcolor='#e74c3c')        self.root.after(300, lambda: self.input_entry.configure(            highlightbackground='#e0e0e0', highlightcolor='#74b9ff'))    # 清空输入框    self.input_var.set('')    self.update_stats_display()def word_complete(self):    self.words_completed += 1    self.word_bank.mark_practiced(self.current_words[self.current_index]['en'])    self.total_practiced_session += 1    if self.current_mode == 'dictation':        self.dictation_completed += 1    self.current_index += 1    self.current_letter_index = 0    self.input_var.set('')    if self.current_index >= len(self.current_words):        self.group_complete()    else:        self.show_current_word()def group_complete(self):    self.is_active = False    self.input_entry.configure(state=tk.DISABLED)    self.clear_highlight()    # 显示完成信息    accuracy = self.get_accuracy()    wpm = self.get_wpm()    self.meaning_var.set(f"正确率: {accuracy}% | 速度: {wpm} WPM")    # 清除字母显示    for lbl in self.letter_labels:        lbl.destroy()    self.letter_labels.clear()    self.word_display_var.set("🎉 本组完成!")    self.word_label.configure(font=('Microsoft YaHei', 24), fg='#27ae60')    self.word_label.pack()    self.hint_var.set("可以重新练习本组或切换下一组")    self.btn_next.configure(state=tk.NORMAL)    self.update_word_stats()def play_beep(self, correct):    """简单的系统提示音"""    try:        import winsound        freq = 880 if correct else 220        winsound.Beep(freq, 80)    except Exception:        pass# ===== 统计相关 =====def get_accuracy(self):    if self.total_keystrokes == 0:        return 0    return round((self.correct_count / self.total_keystrokes) * 100)def get_wpm(self):    if not self.start_time or self.words_completed == 0:        return 0    minutes = (time.time() - self.start_time) / 60    if minutes < 0.01:        return 0    return round(self.words_completed / minutes)def update_stats_display(self):    self.stat_vars['accuracy'].set(f"{self.get_accuracy()}%")    self.stat_vars['wpm'].set(str(self.get_wpm()))    total_in_group = len(self.current_words) if self.current_words else 0    self.stat_vars['group_prog'].set(f"{min(self.current_index + 1, total_in_group)}/{total_in_group}")    all_words = self.word_bank.get_homerow() if self.current_mode == 'homerow' \        else self.word_bank.get_all()    self.stat_vars['total_prog'].set(f"{self.total_practiced_session}/{len(all_words)}")    dict_total = len(self.current_words) if self.current_mode == 'dictation' else 0    self.stat_vars['dict_prog'].set(f"{self.dictation_completed}/{dict_total}")def update_word_stats(self):    stats = self.word_bank.get_stats()    self.lbl_total.configure(text=f"总词: {stats['total']}")    self.lbl_practiced.configure(text=f"已练: {stats['practiced']}")    self.lbl_errors.configure(text=f"错过: {stats['errors']}")# ===== 模式与设置 =====def set_mode(self, mode):    self.current_mode = mode    self.update_mode_buttons()def update_mode_buttons(self):    for m, btn in self.mode_buttons.items():        if m == self.current_mode:            btn.configure(bg='#74b9ff', fg='white')        else:            btn.configure(bg='#f0f0f0', fg='#555')def on_slider_change(self, val):    self.group_size = int(val)def on_settings_change(self):    self.sound_enabled = self.sound_var.get()    self.case_sensitive = self.case_var.get()    self.show_translation = self.trans_var.get()# ===== 词库操作 =====def import_pdf(self):    filepaths = filedialog.askopenfilenames(        title="选择 PDF 文件",        filetypes=[("PDF 文件", "*.pdf"), ("文本文件", "*.txt")]    )    if not filepaths:        return    total_added = 0    for fp in filepaths:        try:            text = self.extract_text_from_file(fp)            added = self.word_bank.import_from_text(text)            total_added += added        except Exception as e:            messagebox.showerror("错误", f"处理 {os.path.basename(fp)} 时出错:\n{e}")    if total_added > 0:        messagebox.showinfo("导入成功", f"共导入 {total_added} 个新单词")        self.update_word_stats()def extract_text_from_file(self, filepath):    """从文件提取文本"""    ext = os.path.splitext(filepath)[1].lower()    if ext == '.txt':        with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:            return f.read()    if ext == '.pdf':        # 尝试使用 PyPDF2 或 pdfplumber        try:            import PyPDF2            with open(filepath, 'rb') as f:                reader = PyPDF2.PdfReader(f)                text = ''                for page in reader.pages:                    text += page.extract_text() or ''                return text        except ImportError:            pass        try:            import pdfplumber            with pdfplumber.open(filepath) as pdf:                text = ''                for page in pdf.pages:                    text += page.extract_text() or ''                return text        except ImportError:            pass        # 回退:尝试直接读取二进制中的文本        with open(filepath, 'rb') as f:            content = f.read().decode('utf-8', errors='ignore')            words = re.findall(r'\b[a-zA-Z]{2,}\b', content)            if words:                return ' '.join(words)        raise Exception("需要安装 PyPDF2 或 pdfplumber 库来解析 PDF\n"                        "运行: pip install PyPDF2")    # 其他格式尝试直接读取    with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:        return f.read()def import_json(self):    filepath = filedialog.askopenfilename(        title="选择 JSON 词库文件",        filetypes=[("JSON 文件", "*.json")]    )    if not filepath:        return    try:        with open(filepath, 'r', encoding='utf-8') as f:            data = json.load(f)        self.word_bank.import_json(data)        self.update_word_stats()        messagebox.showinfo("成功", "词库导入成功!")    except Exception as e:        messagebox.showerror("错误", f"导入失败:\n{e}")def edit_words(self):    """弹出词库编辑窗口"""    edit_win = tk.Toplevel(self.root)    edit_win.title("编辑词库")    edit_win.geometry("500x450")    edit_win.configure(bg='white')    edit_win.transient(self.root)    edit_win.grab_set()    tk.Label(edit_win, text="格式:每行一个,英文单词|中文释义",             font=('Microsoft YaHei', 10), fg='#888', bg='white').pack(        anchor='w', padx=16, pady=(12, 4))    text_frame = tk.Frame(edit_win, bg='white', padx=16)    text_frame.pack(fill=tk.BOTH, expand=True)    scrollbar = tk.Scrollbar(text_frame)    scrollbar.pack(side=tk.RIGHT, fill=tk.Y)    text_widget = tk.Text(text_frame, font=('Consolas', 11), wrap=tk.NONE,                          yscrollcommand=scrollbar.set, relief='flat',                          highlightthickness=1, highlightbackground='#e0e0e0')    text_widget.pack(fill=tk.BOTH, expand=True)    scrollbar.config(command=text_widget.yview)    # 填充内容    words = self.word_bank.get_all()    content = '\n'.join(f"{w['en']}|{w['zh']}" for w in words)    text_widget.insert('1.0', content)    def save_words():        text = text_widget.get('1.0', tk.END).strip()        lines = [l.strip() for l in text.split('\n') if l.strip()]        new_words = []        for line in lines:            parts = line.split('|')            en = parts[0].strip() if parts else ''            zh = parts[1].strip() if len(parts) > 1 else '待补充释义'            if en:                new_words.append({'en': en, 'zh': zh})        self.word_bank.set_words(new_words)        self.update_word_stats()        edit_win.destroy()        messagebox.showinfo("成功", "词库已保存!")    btn_frame = tk.Frame(edit_win, bg='white', padx=16, pady=12)    btn_frame.pack(fill=tk.X)    tk.Button(btn_frame, text="保存", command=save_words,              bg='#74b9ff', fg='white', relief='flat',              font=('Microsoft YaHei', 11), padx=20, cursor='hand2').pack(side=tk.RIGHT)def clear_records(self):    if messagebox.askyesno("确认", "确定要清空所有练习记录吗?\n词库不会被删除。"):        self.word_bank.clear_records()        self.update_word_stats()        messagebox.showinfo("完成", "练习记录已清空")===== 程序入口 =====if name == 'main':root = tk.Tk()app = TypingTutorApp(root)root.mainloop()

最新文章

随机文章