新增测验模式:支持英选汉和汉选英两种题型,含答题判分和成绩统计

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 23:35:48 +08:00
parent f5dd7fa724
commit a1c3194570
7 changed files with 340 additions and 8 deletions
@@ -0,0 +1,8 @@
import Foundation
enum AppRoute: Hashable {
case category(WordCategory)
case word(Word)
case quizSetup(QuizSetupRequest)
case quiz(QuizConfig)
}
@@ -0,0 +1,47 @@
import Foundation
enum QuizMode: String, Hashable {
case enToZh = "英选汉"
case zhToEn = "汉选英"
}
struct QuizSetupRequest: Hashable {
let mode: QuizMode
let words: [Word]
}
struct QuizConfig: Hashable {
let mode: QuizMode
let words: [Word]
}
struct QuizQuestion: Identifiable {
var id: String { word.id }
let word: Word
let prompt: String
let options: [String]
let answer: String
}
enum QuizGenerator {
static func makeQuestions(mode: QuizMode, words: [Word]) -> [QuizQuestion] {
words.shuffled().map { word in
let distractors = words.filter { $0 != word }.shuffled().prefix(3)
let options = (distractors.map { optionText($0, mode) } + [optionText(word, mode)]).shuffled()
return QuizQuestion(
word: word,
prompt: promptText(word, mode),
options: options,
answer: optionText(word, mode)
)
}
}
private static func promptText(_ word: Word, _ mode: QuizMode) -> String {
mode == .enToZh ? word.word : word.meaning
}
private static func optionText(_ word: Word, _ mode: QuizMode) -> String {
mode == .enToZh ? word.meaning : word.word
}
}
@@ -2,11 +2,12 @@ import SwiftUI
struct CategoryListView: View { struct CategoryListView: View {
let categories: [WordCategory] let categories: [WordCategory]
@State private var path: [AppRoute] = []
var body: some View { var body: some View {
NavigationStack { NavigationStack(path: $path) {
List(categories) { category in List(categories) { category in
NavigationLink(value: category) { NavigationLink(value: AppRoute.category(category)) {
HStack(spacing: 16) { HStack(spacing: 16) {
Image(systemName: category.icon) Image(systemName: category.icon)
.font(.title2) .font(.title2)
@@ -25,8 +26,17 @@ struct CategoryListView: View {
} }
} }
.navigationTitle("单词分类") .navigationTitle("单词分类")
.navigationDestination(for: WordCategory.self) { category in .navigationDestination(for: AppRoute.self) { route in
WordListView(category: category) switch route {
case .category(let category):
WordListView(category: category) { path.append($0) }
case .word(let word):
WordDetailView(word: word)
case .quizSetup(let request):
QuizSetupView(request: request)
case .quiz(let config):
QuizView(config: config)
}
} }
} }
} }
@@ -0,0 +1,70 @@
import SwiftUI
struct QuizResultView: View {
let total: Int
let wrongWords: [Word]
let duration: TimeInterval
let onRestart: () -> Void
let onExit: () -> Void
private var correctCount: Int { total - wrongWords.count }
private var durationText: String {
let seconds = Int(duration)
return seconds >= 60 ? "\(seconds / 60)\(seconds % 60)" : "\(seconds)"
}
var body: some View {
List {
Section {
VStack(spacing: 8) {
Text("\(correctCount) / \(total)")
.font(.system(size: 44, weight: .bold))
Text("用时 \(durationText)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
if !wrongWords.isEmpty {
Section("错题(\(wrongWords.count)") {
ForEach(wrongWords) { word in
NavigationLink(value: AppRoute.word(word)) {
HStack {
Text(word.word)
.font(.headline)
Spacer()
Text(word.meaning)
.foregroundStyle(.secondary)
}
}
}
}
}
Section {
Button("再来一轮", action: onRestart)
.frame(maxWidth: .infinity)
Button("返回", action: onExit)
.frame(maxWidth: .infinity)
.foregroundStyle(.secondary)
}
}
}
}
#Preview {
NavigationStack {
QuizResultView(
total: 10,
wrongWords: [
Word(word: "red", phonetic: "/red/", meaning: "红色", example: "The apple is red.", exampleMeaning: "苹果是红色的。")
],
duration: 75,
onRestart: {},
onExit: {}
)
}
}
@@ -0,0 +1,39 @@
import SwiftUI
struct QuizSetupView: View {
let request: QuizSetupRequest
var body: some View {
List {
NavigationLink(value: AppRoute.quiz(QuizConfig(mode: request.mode, words: request.words))) {
VStack(alignment: .leading, spacing: 4) {
Text("全部测试")
.font(.headline)
Text("\(request.words.count)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
NavigationLink(value: AppRoute.quiz(QuizConfig(mode: request.mode, words: Array(request.words.shuffled().prefix(10))))) {
VStack(alignment: .leading, spacing: 4) {
Text("随机十个")
.font(.headline)
Text("从词库中随机抽取 10 题")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}
.navigationTitle(request.mode.rawValue)
}
}
#Preview {
NavigationStack {
QuizSetupView(request: QuizSetupRequest(mode: .enToZh, words: [
Word(word: "red", phonetic: "/red/", meaning: "红色", example: "The apple is red.", exampleMeaning: "苹果是红色的。")
]))
}
}
@@ -0,0 +1,142 @@
import SwiftUI
struct QuizView: View {
let config: QuizConfig
@Environment(SpeechService.self) private var speech
@Environment(\.dismiss) private var dismiss
@State private var questions: [QuizQuestion] = []
@State private var currentIndex = 0
@State private var selectedOption: String?
@State private var wrongWords: [Word] = []
@State private var finished = false
@State private var startDate = Date.now
private var current: QuizQuestion? {
questions.indices.contains(currentIndex) ? questions[currentIndex] : nil
}
var body: some View {
Group {
if finished {
QuizResultView(
total: questions.count,
wrongWords: wrongWords,
duration: Date.now.timeIntervalSince(startDate),
onRestart: restart,
onExit: { dismiss() }
)
} else if let question = current {
questionView(question)
}
}
.navigationTitle(config.mode.rawValue)
.navigationBarTitleDisplayMode(.inline)
.onAppear {
if questions.isEmpty { restart() }
}
}
private func questionView(_ question: QuizQuestion) -> some View {
VStack(spacing: 24) {
VStack(spacing: 8) {
ProgressView(value: Double(currentIndex), total: Double(questions.count))
Text("\(currentIndex + 1) / \(questions.count)")
.font(.caption)
.foregroundStyle(.secondary)
}
VStack(spacing: 12) {
Text(question.prompt)
.font(.system(size: config.mode == .enToZh ? 44 : 34, weight: .bold))
if config.mode == .enToZh {
Button {
speech.speak(question.prompt)
} label: {
Image(systemName: "speaker.wave.2.fill")
.font(.title2)
}
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
VStack(spacing: 12) {
ForEach(question.options, id: \.self) { option in
Button {
select(option, for: question)
} label: {
Text(option)
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.bordered)
.tint(optionColor(option, for: question))
.disabled(selectedOption != nil)
}
}
if let selectedOption {
VStack(spacing: 12) {
Text(selectedOption == question.answer ? "回答正确" : "正确答案:\(question.answer)")
.font(.headline)
.foregroundStyle(selectedOption == question.answer ? .green : .red)
Button(currentIndex + 1 < questions.count ? "下一题" : "查看成绩") {
next()
}
.buttonStyle(.borderedProminent)
}
}
Spacer()
}
.padding()
}
private func optionColor(_ option: String, for question: QuizQuestion) -> Color {
guard let selectedOption else { return .accentColor }
if option == question.answer { return .green }
if option == selectedOption { return .red }
return .gray
}
private func select(_ option: String, for question: QuizQuestion) {
guard selectedOption == nil else { return }
selectedOption = option
if option != question.answer {
wrongWords.append(question.word)
}
}
private func next() {
if currentIndex + 1 < questions.count {
currentIndex += 1
selectedOption = nil
} else {
finished = true
}
}
private func restart() {
questions = QuizGenerator.makeQuestions(mode: config.mode, words: config.words)
currentIndex = 0
selectedOption = nil
wrongWords = []
finished = false
startDate = .now
}
}
#Preview {
NavigationStack {
QuizView(config: QuizConfig(mode: .enToZh, words: [
Word(word: "red", phonetic: "/red/", meaning: "红色", example: "The apple is red.", exampleMeaning: "苹果是红色的。"),
Word(word: "blue", phonetic: "/bluː/", meaning: "蓝色", example: "The sky is blue.", exampleMeaning: "天空是蓝色的。"),
Word(word: "green", phonetic: "/ɡriːn/", meaning: "绿色", example: "The grass is green.", exampleMeaning: "草地是绿色的。"),
Word(word: "yellow", phonetic: "/ˈjeloʊ/", meaning: "黄色", example: "The banana is yellow.", exampleMeaning: "香蕉是黄色的。")
]))
.environment(SpeechService())
}
}
@@ -2,10 +2,11 @@ import SwiftUI
struct WordListView: View { struct WordListView: View {
let category: WordCategory let category: WordCategory
let onRoute: (AppRoute) -> Void
var body: some View { var body: some View {
List(category.words) { word in List(category.words) { word in
NavigationLink(value: word) { NavigationLink(value: AppRoute.word(word)) {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(word.word) Text(word.word)
.font(.headline) .font(.headline)
@@ -17,8 +18,23 @@ struct WordListView: View {
} }
} }
.navigationTitle(category.name) .navigationTitle(category.name)
.navigationDestination(for: Word.self) { word in .toolbar {
WordDetailView(word: word) ToolbarItem(placement: .primaryAction) {
Menu {
Button("英选汉") { startQuiz(.enToZh) }
Button("汉选英") { startQuiz(.zhToEn) }
} label: {
Label("测验", systemImage: "questionmark.circle")
}
}
}
}
private func startQuiz(_ mode: QuizMode) {
if category.words.count <= 10 {
onRoute(.quiz(QuizConfig(mode: mode, words: category.words)))
} else {
onRoute(.quizSetup(QuizSetupRequest(mode: mode, words: category.words)))
} }
} }
} }
@@ -27,6 +43,6 @@ struct WordListView: View {
NavigationStack { NavigationStack {
WordListView(category: WordCategory(id: "colors", name: "颜色", icon: "paintpalette.fill", words: [ WordListView(category: WordCategory(id: "colors", name: "颜色", icon: "paintpalette.fill", words: [
Word(word: "red", phonetic: "/red/", meaning: "红色", example: "The apple is red.", exampleMeaning: "苹果是红色的。") Word(word: "red", phonetic: "/red/", meaning: "红色", example: "The apple is red.", exampleMeaning: "苹果是红色的。")
])) ])) { _ in }
} }
} }