新增学习记录功能,拼写验证后自动记录,首页可查看时间线

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 21:29:10 +08:00
parent 0df02d487e
commit 41841896dd
4 changed files with 155 additions and 0 deletions
@@ -0,0 +1,73 @@
import Foundation
struct LearnedRecord: Codable {
let word: Word
let learnedAt: Date
}
final class LearnedStore {
static let shared = LearnedStore()
private let key = "learned_records"
private(set) var records: [LearnedRecord] {
didSet {
guard let data = try? JSONEncoder().encode(records) else { return }
UserDefaults.standard.set(data, forKey: key)
}
}
private init() {
if let data = UserDefaults.standard.data(forKey: key),
let decoded = try? JSONDecoder().decode([LearnedRecord].self, from: data) {
records = decoded
} else {
records = []
}
}
func record(_ word: Word) {
guard !records.contains(where: { $0.word.id == word.id }) else { return }
records.append(LearnedRecord(word: word, learnedAt: Date()))
}
func isRecorded(_ word: Word) -> Bool {
records.contains { $0.word.id == word.id }
}
var sectioned: [(title: String, items: [LearnedRecord])] {
let sorted = records.sorted { $0.learnedAt > $1.learnedAt }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "yyyy-MM-dd"
var groups: [(String, [LearnedRecord])] = []
var seen: Set<String> = []
for record in sorted {
let dateStr = formatter.string(from: record.learnedAt)
let title = sectionTitle(dateStr)
if !seen.contains(title) {
groups.append((title, []))
seen.insert(title)
}
if let index = groups.firstIndex(where: { $0.0 == title }) {
groups[index].1.append(record)
}
}
return groups
}
private func sectionTitle(_ dateStr: String) -> String {
let today = DateFormatter()
today.locale = Locale(identifier: "zh_CN")
today.dateFormat = "yyyy-MM-dd"
let todayStr = today.string(from: Date())
if dateStr == todayStr { return "今天" }
let calendar = Calendar.current
if let yesterday = calendar.date(byAdding: .day, value: -1, to: Date()) {
let yesterdayStr = today.string(from: yesterday)
if dateStr == yesterdayStr { return "昨天" }
}
return dateStr
}
}
@@ -14,6 +14,12 @@ final class CategoryListViewController: UITableViewController {
override func viewDidLoad() {
super.viewDidLoad()
title = "背单词"
navigationItem.rightBarButtonItem = UIBarButtonItem(
image: UIImage(systemName: "list.bullet.clipboard"),
style: .plain,
target: self,
action: #selector(showHistory)
)
tableView.register(CategoryCardCell.self, forCellReuseIdentifier: "cell")
tableView.separatorStyle = .none
tableView.backgroundColor = .systemBackground
@@ -23,6 +29,10 @@ final class CategoryListViewController: UITableViewController {
}
}
@objc private func showHistory() {
navigationController?.pushViewController(HistoryViewController(), animated: true)
}
private func showError(_ message: String) {
let label = UILabel()
label.text = "词库加载失败\n\(message)"
@@ -0,0 +1,71 @@
import UIKit
final class HistoryViewController: UITableViewController {
private let sections: [(title: String, items: [LearnedRecord])]
init() {
sections = LearnedStore.shared.sectioned
super.init(style: .insetGrouped)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func viewDidLoad() {
super.viewDidLoad()
title = "学习记录"
navigationItem.largeTitleDisplayMode = .never
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
tableView.backgroundColor = .systemBackground
updateEmptyState()
}
private func updateEmptyState() {
if sections.isEmpty {
let label = UILabel()
label.text = "暂无学习记录"
label.textAlignment = .center
label.textColor = .secondaryLabel
label.font = .preferredFont(forTextStyle: .body)
let bg = UIView()
bg.addSubview(label)
label.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: bg.centerXAnchor),
label.centerYAnchor.constraint(equalTo: bg.centerYAnchor)
])
tableView.backgroundView = bg
}
}
override func numberOfSections(in tableView: UITableView) -> Int {
sections.count
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
sections[section].title
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
sections[section].items.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
let record = sections[indexPath.section].items[indexPath.row]
var config = cell.defaultContentConfiguration()
config.text = record.word.word
config.secondaryText = "\(record.word.phonetic) \(record.word.meaning)"
config.image = UIImage(systemName: "text.book.closed")
config.imageProperties.tintColor = Theme.Color.accent
cell.contentConfiguration = config
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
Haptics.selection()
let record = sections[indexPath.section].items[indexPath.row]
let detail = WordDetailViewController(word: record.word, categoryName: "")
navigationController?.pushViewController(detail, animated: true)
}
}
@@ -357,6 +357,7 @@ final class QuizViewController: UIViewController {
private func submitSpelling(_ typed: String, for question: QuizQuestion) {
guard selectedOption == nil else { return }
selectedOption = typed
LearnedStore.shared.record(question.word)
let correct = typed.lowercased() == question.answer.lowercased()
if !correct {
wrongWords.append(question.word)