新增测验模式:支持英选汉和汉选英两种题型,含答题判分和成绩统计
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user