Compare commits

..

10 Commits

Author SHA1 Message Date
fish 7728a22048 学习记录卡片统计信息按时间、次数、正确率顺序展示
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-21 22:18:52 +08:00
fish ad0ff1ca7a 学习记录展示具体学习时间,精确到时分秒
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-21 22:17:15 +08:00
fish 106b60b58f 单词详情页新增历史记录入口,可查看单个单词的学习统计
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-21 21:57:24 +08:00
fish 63f5e7ee76 修复学习记录页面返回后数据不刷新,改为 viewWillAppear 重新加载统计
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-21 21:53:02 +08:00
fish cc032110d4 学习记录新增正确率统计,每次拼写验证追踪对错并计算百分比
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-21 21:50:40 +08:00
fish f48fd5c3d8 学习记录新增学习次数统计,每次拼写验证自动累加
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-21 21:48:29 +08:00
fish 35300e9aa1 日历每周从周一开始
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-21 21:36:36 +08:00
fish ad58d9ea5a 学习记录新增日历视图,支持按日期切换查看学习记录
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-21 21:35:45 +08:00
fish 673e35aec2 学习记录卡片间距与单词列表保持一致
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-21 21:32:24 +08:00
fish 74dcaee335 学习记录页面改用卡片风格展示,与首页单词列表保持一致
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-21 21:31:06 +08:00
6 changed files with 232 additions and 20 deletions
@@ -2,7 +2,13 @@ import Foundation
struct LearnedRecord: Codable { struct LearnedRecord: Codable {
let word: Word let word: Word
let learnedAt: Date var learnedAt: Date
var count: Int = 1
var correctCount: Int = 0
var accuracy: Int {
count > 0 ? Int(Double(correctCount) / Double(count) * 100) : 0
}
} }
final class LearnedStore { final class LearnedStore {
@@ -25,9 +31,20 @@ final class LearnedStore {
} }
} }
func record(_ word: Word) { func record(_ word: Word, correct: Bool) {
guard !records.contains(where: { $0.word.id == word.id }) else { return } if let index = records.firstIndex(where: { $0.word.id == word.id }) {
records.append(LearnedRecord(word: word, learnedAt: Date())) records[index].learnedAt = Date()
records[index].count += 1
if correct { records[index].correctCount += 1 }
} else {
var rec = LearnedRecord(word: word, learnedAt: Date())
if correct { rec.correctCount = 1 }
records.append(rec)
}
}
func record(for word: Word) -> LearnedRecord? {
records.first { $0.word.id == word.id }
} }
func isRecorded(_ word: Word) -> Bool { func isRecorded(_ word: Word) -> Bool {
@@ -1,26 +1,49 @@
import UIKit import UIKit
final class HistoryViewController: UITableViewController { final class HistoryViewController: UITableViewController {
private let sections: [(title: String, items: [LearnedRecord])] private var sections: [(title: String, items: [LearnedRecord])] = []
private var speechObserver: NSObjectProtocol?
private let timeFormatter: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = "HH:mm:ss"
return f
}()
init() { init() {
sections = LearnedStore.shared.sectioned super.init(style: .plain)
super.init(style: .insetGrouped)
} }
@available(*, unavailable) @available(*, unavailable)
required init?(coder: NSCoder) { fatalError() } required init?(coder: NSCoder) { fatalError() }
deinit {
if let speechObserver {
NotificationCenter.default.removeObserver(speechObserver)
}
}
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
title = "学习记录" title = "学习记录"
navigationItem.largeTitleDisplayMode = .never navigationItem.largeTitleDisplayMode = .never
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") tableView.register(WordCardCell.self, forCellReuseIdentifier: "cell")
tableView.separatorStyle = .none
tableView.backgroundColor = .systemBackground tableView.backgroundColor = .systemBackground
updateEmptyState() tableView.contentInset.top = 8
speechObserver = NotificationCenter.default.addObserver(
forName: SpeechService.speakingDidChangeNotification, object: nil, queue: .main
) { [weak self] _ in
for cell in self?.tableView.visibleCells ?? [] {
(cell as? WordCardCell)?.updateSpeakerState()
}
}
} }
private func updateEmptyState() { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
sections = LearnedStore.shared.sectioned
if sections.isEmpty { if sections.isEmpty {
let label = UILabel() let label = UILabel()
label.text = "暂无学习记录" label.text = "暂无学习记录"
@@ -35,7 +58,10 @@ final class HistoryViewController: UITableViewController {
label.centerYAnchor.constraint(equalTo: bg.centerYAnchor) label.centerYAnchor.constraint(equalTo: bg.centerYAnchor)
]) ])
tableView.backgroundView = bg tableView.backgroundView = bg
} else {
tableView.backgroundView = nil
} }
tableView.reloadData()
} }
override func numberOfSections(in tableView: UITableView) -> Int { override func numberOfSections(in tableView: UITableView) -> Int {
@@ -51,14 +77,18 @@ final class HistoryViewController: UITableViewController {
} }
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! WordCardCell
let record = sections[indexPath.section].items[indexPath.row] let record = sections[indexPath.section].items[indexPath.row]
var config = cell.defaultContentConfiguration() let time = timeFormatter.string(from: record.learnedAt)
config.text = record.word.word cell.configure(with: record.word, count: record.count, accuracy: record.accuracy, time: time)
config.secondaryText = "\(record.word.phonetic) \(record.word.meaning)" cell.onSpeak = { word in
config.image = UIImage(systemName: "text.book.closed") SpeechService.shared.speak(word.word)
config.imageProperties.tintColor = Theme.Color.accent }
cell.contentConfiguration = config cell.onLearn = { [weak self] word in
guard let self else { return }
let config = QuizConfig(mode: .spelling, words: [word], isStudyMode: true)
navigationController?.pushViewController(QuizViewController(config: config), animated: true)
}
return cell return cell
} }
@@ -357,8 +357,8 @@ final class QuizViewController: UIViewController {
private func submitSpelling(_ typed: String, for question: QuizQuestion) { private func submitSpelling(_ typed: String, for question: QuizQuestion) {
guard selectedOption == nil else { return } guard selectedOption == nil else { return }
selectedOption = typed selectedOption = typed
LearnedStore.shared.record(question.word)
let correct = typed.lowercased() == question.answer.lowercased() let correct = typed.lowercased() == question.answer.lowercased()
LearnedStore.shared.record(question.word, correct: correct)
if !correct { if !correct {
wrongWords.append(question.word) wrongWords.append(question.word)
} }
@@ -5,6 +5,7 @@ final class WordCardCell: UITableViewCell {
private let wordLabel = UILabel() private let wordLabel = UILabel()
private let phoneticLabel = UILabel() private let phoneticLabel = UILabel()
private let meaningLabel = UILabel() private let meaningLabel = UILabel()
private let countLabel = UILabel()
private let learnButton = UIButton(type: .system) private let learnButton = UIButton(type: .system)
private let speakerButton = UIButton(type: .system) private let speakerButton = UIButton(type: .system)
private var word: Word? private var word: Word?
@@ -37,6 +38,10 @@ final class WordCardCell: UITableViewCell {
meaningLabel.font = .preferredFont(forTextStyle: .subheadline) meaningLabel.font = .preferredFont(forTextStyle: .subheadline)
meaningLabel.textColor = .secondaryLabel meaningLabel.textColor = .secondaryLabel
countLabel.font = .preferredFont(forTextStyle: .caption2)
countLabel.textColor = Theme.Color.accent
countLabel.setContentHuggingPriority(.required, for: .horizontal)
let textStack = UIStackView(arrangedSubviews: [wordLabel, phoneticLabel, meaningLabel]) let textStack = UIStackView(arrangedSubviews: [wordLabel, phoneticLabel, meaningLabel])
textStack.axis = .vertical textStack.axis = .vertical
textStack.spacing = 8 textStack.spacing = 8
@@ -57,7 +62,7 @@ final class WordCardCell: UITableViewCell {
}, for: .touchUpInside) }, for: .touchUpInside)
speakerButton.setContentHuggingPriority(.required, for: .horizontal) speakerButton.setContentHuggingPriority(.required, for: .horizontal)
let row = UIStackView(arrangedSubviews: [textStack, learnButton, speakerButton]) let row = UIStackView(arrangedSubviews: [textStack, countLabel, learnButton, speakerButton])
row.axis = .horizontal row.axis = .horizontal
row.alignment = .center row.alignment = .center
row.spacing = 8 row.spacing = 8
@@ -83,9 +88,16 @@ final class WordCardCell: UITableViewCell {
wordLabel.text = word.word wordLabel.text = word.word
phoneticLabel.text = word.phonetic phoneticLabel.text = word.phonetic
meaningLabel.text = word.meaning meaningLabel.text = word.meaning
countLabel.isHidden = true
updateSpeakerState() updateSpeakerState()
} }
func configure(with word: Word, count: Int, accuracy: Int, time: String) {
configure(with: word)
countLabel.text = "\(time) · \(count)次 · \(accuracy)%"
countLabel.isHidden = false
}
func updateSpeakerState() { func updateSpeakerState() {
guard let word else { return } guard let word else { return }
let speaking = SpeechService.shared.isSpeaking(word.word) let speaking = SpeechService.shared.isSpeaking(word.word)
@@ -31,7 +31,10 @@ final class WordDetailViewController: UIViewController {
view.backgroundColor = .systemBackground view.backgroundColor = .systemBackground
title = categoryName title = categoryName
navigationItem.largeTitleDisplayMode = .never navigationItem.largeTitleDisplayMode = .never
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "学习", style: .plain, target: self, action: #selector(startSpelling)) navigationItem.rightBarButtonItems = [
UIBarButtonItem(image: UIImage(systemName: "clock.arrow.circlepath"), style: .plain, target: self, action: #selector(showHistory)),
UIBarButtonItem(title: "学习", style: .plain, target: self, action: #selector(startSpelling))
]
buildLayout() buildLayout()
updateSpeakState() updateSpeakState()
speechObserver = NotificationCenter.default.addObserver( speechObserver = NotificationCenter.default.addObserver(
@@ -187,6 +190,10 @@ final class WordDetailViewController: UIViewController {
return card return card
} }
@objc private func showHistory() {
navigationController?.pushViewController(WordHistoryViewController(word: word), animated: true)
}
@objc private func startSpelling() { @objc private func startSpelling() {
Haptics.selection() Haptics.selection()
let config = QuizConfig(mode: .spelling, words: [word], isStudyMode: true) let config = QuizConfig(mode: .spelling, words: [word], isStudyMode: true)
@@ -0,0 +1,146 @@
import UIKit
final class WordHistoryViewController: UIViewController {
private let word: Word
private let record: LearnedRecord?
init(word: Word) {
self.word = word
record = LearnedStore.shared.record(for: word)
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func viewDidLoad() {
super.viewDidLoad()
title = "学习记录"
navigationItem.largeTitleDisplayMode = .never
view.backgroundColor = .systemBackground
buildLayout()
}
private func buildLayout() {
guard let record else {
let label = UILabel()
label.text = "暂无学习记录"
label.textAlignment = .center
label.textColor = .secondaryLabel
label.font = .preferredFont(forTextStyle: .body)
view.addSubview(label)
label.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
return
}
let wordCard = makeWordCard(record)
let statsCard = makeStatsCard(record)
let stack = UIStackView(arrangedSubviews: [wordCard, statsCard])
stack.axis = .vertical
stack.spacing = 20
let scrollView = UIScrollView()
scrollView.alwaysBounceVertical = true
view.addSubview(scrollView)
scrollView.addSubview(stack)
scrollView.translatesAutoresizingMaskIntoConstraints = false
stack.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 20),
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16),
stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16),
stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32)
])
}
private func makeWordCard(_ record: LearnedRecord) -> UIView {
let card = UIView()
card.backgroundColor = Theme.Color.cardBackground
card.layer.cornerRadius = Theme.Metrics.cardRadius
let wordLabel = UILabel()
wordLabel.text = record.word.word
wordLabel.font = .preferredFont(forTextStyle: .headline)
let phoneticLabel = UILabel()
phoneticLabel.text = record.word.phonetic
phoneticLabel.font = .preferredFont(forTextStyle: .caption1)
phoneticLabel.textColor = .secondaryLabel
let meaningLabel = UILabel()
meaningLabel.text = record.word.meaning
meaningLabel.font = .preferredFont(forTextStyle: .subheadline)
meaningLabel.textColor = .secondaryLabel
let textStack = UIStackView(arrangedSubviews: [wordLabel, phoneticLabel, meaningLabel])
textStack.axis = .vertical
textStack.spacing = 8
card.addSubview(textStack)
textStack.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
textStack.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
textStack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
textStack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
textStack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -14)
])
return card
}
private func makeStatsCard(_ record: LearnedRecord) -> UIView {
let card = UIView()
card.backgroundColor = Theme.Color.cardBackground
card.layer.cornerRadius = Theme.Metrics.cardRadius
let title = UILabel()
title.text = "学习统计"
title.font = .preferredFont(forTextStyle: .caption1)
title.textColor = .secondaryLabel
let countLabel = UILabel()
countLabel.text = "\(record.count)"
countLabel.font = .systemFont(ofSize: 36, weight: .bold)
let accuracyLabel = UILabel()
accuracyLabel.text = "正确率 \(record.accuracy)%"
accuracyLabel.font = .preferredFont(forTextStyle: .subheadline)
accuracyLabel.textColor = Theme.Color.correct
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
let dateLabel = UILabel()
dateLabel.text = "最近学习:\(formatter.string(from: record.learnedAt))"
dateLabel.font = .preferredFont(forTextStyle: .caption1)
dateLabel.textColor = .tertiaryLabel
let statsStack = UIStackView(arrangedSubviews: [countLabel, accuracyLabel])
statsStack.axis = .horizontal
statsStack.spacing = 16
statsStack.alignment = .lastBaseline
let stack = UIStackView(arrangedSubviews: [title, statsStack, dateLabel])
stack.axis = .vertical
stack.spacing = 12
card.addSubview(stack)
stack.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 16),
stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -16)
])
return card
}
}