"""英语指法打字练习系统 - 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()