wangdl f445906fa7 feat(quiz): integrate QuizQuestionType enum into iOS quiz views
- Add QuizQuestionType Swift enum with safe factory and displayAnswer
- Add QuizQuestion.questionType computed property
- Replace bare string comparisons with enum switch in QuizTakerView
- Add empty state, unknown type error state, judge localization
- Use displayAnswer in QuizResultView for choice index→text + judge CN
- Add QuizQuestionTypeTests (19 tests)
- Update QuizService with createAttempt/getAttemptQuestions endpoints

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 20:11:27 +08:00

326 lines
20 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
// MARK: - Quiz List
struct QuizListView: View {
let knowledgeBaseId: String
@State private var quizzes: [Quiz] = []
@State private var isLoading = true
@State private var isGenerating = false
var body: some View {
ZStack {
Color.zxBg0.ignoresSafeArea()
VStack(spacing: 0) {
if isLoading {
VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04) }.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if quizzes.isEmpty {
VStack(spacing: 16) {
Image("icon-question").font(.system(size: 40)).foregroundColor(Color.zxF03)
Text("暂无测验").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF04)
Text("基于知识库内容自动生成测验题目").font(.system(size: 12)).foregroundColor(Color.zxF03)
Button {
Task { await generateQuiz() }
} label: {
HStack { if isGenerating { ProgressView().tint(.white) }; Text("生成测验").font(.system(size: 14, weight: .bold)) }
.foregroundColor(.white).frame(height: 48).padding(.horizontal, 32)
.background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: 14))
}.disabled(isGenerating)
}.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
VStack(spacing: 12) {
Button {
Task { await generateQuiz() }
} label: {
HStack { Image("icon-plus").resizable().scaledToFit().frame(width: 16, height: 16); Text("生成新测验") }.font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxPrimary).frame(maxWidth: .infinity).frame(height: 44).background(Color.zxPrimarySoft).clipShape(RoundedRectangle(cornerRadius: 12))
}.disabled(isGenerating)
ForEach(quizzes) { q in
NavigationLink(value: Route.quizTake(quizId: q.id)) {
VStack(alignment: .leading, spacing: 8) {
HStack { Text(q.title ?? "测验").font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0); Spacer(); Image("icon-chevron-right").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(Color.zxF03) }
HStack(spacing: 12) {
Label("\(q.questionCount ?? 0)", systemImage: "list.bullet").font(.system(size: 12)).foregroundColor(Color.zxF04)
Label("选择题/判断/填空", systemImage: "square.grid.3x3").font(.system(size: 12)).foregroundColor(Color.zxF04)
}
}.padding(14).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 14))
}.foregroundColor(.primary)
}
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
}.scrollIndicators(.hidden)
}
}
}
.navigationTitle("测验").navigationBarTitleDisplayMode(.inline).toolbar(.hidden, for: .tabBar).toolbarBackground(.hidden, for: .navigationBar)
.task { await load() }
}
private func load() async {
isLoading = true
do { quizzes = try await QuizService.shared.list(knowledgeBaseId: knowledgeBaseId) } catch {}
isLoading = false
}
private func generateQuiz() async {
isGenerating = true
do {
let _ = try await QuizService.shared.generate(knowledgeBaseId: knowledgeBaseId, questionCount: 5)
await load()
} catch { ZXToastManager.shared.error("生成失败") }
isGenerating = false
}
}
// MARK: - Quiz Taker (M-QUIZ-01-03: Attempt + )
struct QuizTakerView: View {
let quizId: String
@State private var questions: [QuizQuestion] = []
@State private var attemptId: String = ""
@State private var currentIndex = 0
@State private var answers: [String: String] = [:]
@State private var isLoading = true
@State private var isSubmitting = false
@State private var showResult = false
@State private var resultAttemptId = ""
// M-QUIZ-01-03: key
private var cacheKey: String { "quiz_answers_\(attemptId)" }
var body: some View {
ZStack {
Color.zxBg0.ignoresSafeArea()
if isLoading {
VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载测验…").font(.system(size: 14)).foregroundColor(Color.zxF04) }.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if questions.isEmpty {
// M-QUIZ-01-CLEANUP-02:
VStack(spacing: 16) {
Image(systemName: "questionmark.square.dashed").font(.system(size: 36)).foregroundColor(Color.zxF03)
Text("当前测验暂无题目").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF04)
Button { Task { await load() } } label: {
Text("重新加载").font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxPurple)
}
}.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
VStack(spacing: 0) {
// Progress
VStack(spacing: 8) {
HStack {
Text("\(currentIndex + 1) / \(questions.count)").font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxF04)
Spacer()
Text("已答 \(answers.count)").font(.system(size: 12)).foregroundColor(Color.zxF03)
}
GeometryReader { g in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2).fill(Color.zxFill008).frame(height: 4)
RoundedRectangle(cornerRadius: 2).fill(ZXGradient.brandHorizontal).frame(width: g.size.width * CGFloat(currentIndex + 1) / CGFloat(questions.count), height: 4)
}
}.frame(height: 4)
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 16)
ScrollView {
VStack(spacing: 20) {
let question = questions[currentIndex]
VStack(alignment: .leading, spacing: 16) {
Text(question.stem ?? "").font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0).lineSpacing(4)
// M-QUIZ-01-CLEANUP-02: 使 QuizQuestionType
switch question.questionType {
case .choice:
if let options = question.options {
VStack(spacing: 8) {
ForEach(Array(options.enumerated()), id: \.offset) { i, opt in
Button {
setAnswer(question.id, String(i))
} label: {
HStack(spacing: 12) {
Text(["A","B","C","D"][min(i, 3)]).font(.system(size: 14, weight: .bold)).foregroundColor(answers[question.id] == String(i) ? .white : Color.zxPurple).frame(width: 30, height: 30).background(answers[question.id] == String(i) ? Color.zxPurple : Color.zxPurpleBG(0.12)).clipShape(Circle())
Text(opt).font(.system(size: 14)).foregroundColor(Color.zxF0)
Spacer()
}.padding(12).background(answers[question.id] == String(i) ? Color.zxPurpleBG(0.08) : Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 12)).overlay(RoundedRectangle(cornerRadius: 12).stroke(answers[question.id] == String(i) ? Color.zxPurple.opacity(0.2) : Color.clear, lineWidth: 1))
}.foregroundColor(.primary)
}
}
}
case .judge:
HStack(spacing: 12) {
ForEach(["true", "false"], id: \.self) { v in
Button {
setAnswer(question.id, v)
} label: {
Text(v == "true" ? "✓ 正确" : "✗ 错误").font(.system(size: 14, weight: .medium)).foregroundColor(answers[question.id] == v ? .white : Color.zxF0).frame(maxWidth: .infinity).frame(height: 48).background(answers[question.id] == v ? (v == "true" ? Color.zxGreen : Color.zxCoral) : Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 12))
}.foregroundColor(.primary)
}
}
case .fill:
TextField("输入你的答案", text: Binding(get: { answers[question.id] ?? "" }, set: { setAnswer(question.id, $0) })).font(.system(size: 14)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 48).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 12)).overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.zxBorder008, lineWidth: 1))
case nil:
// M-QUIZ-01-CLEANUP-02: fail-closed
VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle").font(.system(size: 24)).foregroundColor(Color.zxCoral)
Text("暂不支持该题型").font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxF04)
}.frame(maxWidth: .infinity).padding(.vertical, 24)
}
}.padding(.horizontal, 20)
}
}
// Navigation buttons
HStack {
Button { if currentIndex > 0 { currentIndex -= 1 } } label: {
HStack { Image("icon-chevron-left"); Text("上一题") }.font(.system(size: 14)).foregroundColor(Color.zxF05).padding(.horizontal, 20).padding(.vertical, 12).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 10))
}.disabled(currentIndex == 0)
Spacer()
if currentIndex < questions.count - 1 {
Button { currentIndex += 1 } label: {
HStack { Text("下一题"); Image("icon-chevron-right") }.font(.system(size: 14, weight: .medium)).foregroundColor(.white).padding(.horizontal, 20).padding(.vertical, 12).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: 10))
}
} else {
Button {
Task { await submit() }
} label: {
HStack { if isSubmitting { ProgressView().tint(.white) }; Text("提交").font(.system(size: 14, weight: .bold)) }.foregroundColor(.white).padding(.horizontal, 32).padding(.vertical, 12).background(Color.zxGreen).clipShape(RoundedRectangle(cornerRadius: 10))
}.disabled(isSubmitting)
}
}.padding(.horizontal, 20).padding(.vertical, 16).background(.ultraThinMaterial)
}
}
}
.navigationBarTitleDisplayMode(.inline).toolbar(.hidden, for: .tabBar).toolbarBackground(.hidden, for: .navigationBar)
.task { await load() }
.onDisappear { saveAnswersToCache() }
}
// M-QUIZ-01-03: Attempt API
private func load() async {
isLoading = true
do {
// Step 1: Attempt
let att = try await QuizService.shared.createAttempt(quizId: quizId)
attemptId = att.id
// Step 2: Attempt API answer/explanation
questions = try await QuizService.shared.getAttemptQuestions(attemptId: attemptId)
// Step 3:
restoreAnswersFromCache()
} catch { ZXToastManager.shared.error("加载失败") }
isLoading = false
}
private func submit() async {
isSubmitting = true
guard !attemptId.isEmpty else { isSubmitting = false; return }
let items = answers.map { QuizAnswerItem(questionId: $0.key, answer: $0.value) }
do {
let _ = try await QuizService.shared.submit(quizId: quizId, attemptId: attemptId, answers: items)
resultAttemptId = attemptId
clearAnswerCache()
showResult = true
} catch { ZXToastManager.shared.error("提交失败") }
isSubmitting = false
}
// M-QUIZ-01-03:
private func setAnswer(_ questionId: String, _ answer: String) {
answers[questionId] = answer
saveAnswersToCache()
}
private func saveAnswersToCache() {
guard !attemptId.isEmpty, !answers.isEmpty else { return }
if let data = try? JSONEncoder().encode(answers) {
UserDefaults.standard.set(data, forKey: cacheKey)
}
}
private func restoreAnswersFromCache() {
guard !attemptId.isEmpty, let data = UserDefaults.standard.data(forKey: cacheKey),
let cached = try? JSONDecoder().decode([String: String].self, from: data) else { return }
answers = cached
}
private func clearAnswerCache() {
UserDefaults.standard.removeObject(forKey: cacheKey)
}
}
// MARK: - Quiz Result (M-QUIZ-01-03: vs )
struct QuizResultView: View {
let quizId: String; let attemptId: String
@State private var result: QuizAttempt?
@State private var isLoading = true
var body: some View {
ZStack {
Color.zxBg0.ignoresSafeArea()
if isLoading {
VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载结果…").font(.system(size: 14)).foregroundColor(Color.zxF04) }.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let r = result {
ScrollView {
VStack(spacing: 16) {
VStack(spacing: 12) {
Text("\(r.score ?? 0)").font(.system(size: 48, weight: .heavy)).foregroundColor(r.score ?? 0 >= 60 ? Color.zxGreen : Color.zxCoral)
Text("答对 \(r.correctCount ?? 0)/\(r.totalQuestions ?? 0)").font(.system(size: 16)).foregroundColor(Color.zxF05)
NavigationLink(value: Route.quizTake(quizId: quizId)) {
Text("重新测验").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 48).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: 14))
}.padding(.horizontal, 20)
}.padding(24).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20)).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
VStack(alignment: .leading, spacing: 12) {
Text("答题详情").font(.system(size: 16, weight: .bold)).foregroundColor(Color.zxF0)
ForEach(r.answers ?? []) { a in
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Image(systemName: a.isCorrect == true ? "checkmark.circle" : "xmark.circle").font(.system(size: 18)).foregroundColor(a.isCorrect == true ? Color.zxGreen : Color.zxCoral)
Text(a.question?.stem ?? "").font(.system(size: 14)).foregroundColor(Color.zxF0).lineLimit(3)
Spacer()
}
// M-QUIZ-01-03 / M-QUIZ-01-CLEANUP-02: 使 QuizQuestionType
let qType = a.question?.questionType
let options = a.question?.options ?? []
let displayUserAnswer = Self.answerDisplayText(raw: a.userAnswer ?? "", type: qType, options: options)
let displayCorrectAnswer = Self.answerDisplayText(raw: a.question?.answer ?? "", type: qType, options: options)
HStack(spacing: 16) {
if !displayUserAnswer.isEmpty {
HStack(spacing: 4) {
Text("你的答案").font(.system(size: 11)).foregroundColor(Color.zxF04)
Text(displayUserAnswer).font(.system(size: 12, weight: .medium)).foregroundColor(a.isCorrect == true ? Color.zxGreen : Color.zxCoral)
}
}
if !displayCorrectAnswer.isEmpty {
HStack(spacing: 4) {
Text("正确答案").font(.system(size: 11)).foregroundColor(Color.zxF04)
Text(displayCorrectAnswer).font(.system(size: 12, weight: .medium)).foregroundColor(Color.zxGreen)
}
}
}
if let exp = a.question?.explanation {
Text(exp).font(.system(size: 12)).foregroundColor(Color.zxF04).lineLimit(4)
}
}.padding(12).background(a.isCorrect == true ? Color.zxGreen.opacity(0.05) : Color.zxCoral.opacity(0.05)).clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
}.scrollIndicators(.hidden)
}
}
.navigationBarTitleDisplayMode(.inline).toolbar(.hidden, for: .tabBar).toolbarBackground(.hidden, for: .navigationBar)
.task { await load() }
}
private func load() async {
isLoading = true
do { result = try await QuizService.shared.results(quizId: quizId, attemptId: attemptId) } catch {}
isLoading = false
}
// M-QUIZ-01-CLEANUP-02: 使 QuizQuestionType.displayAnswer
static func answerDisplayText(raw: String, type: QuizQuestionType?, options: [String]) -> String {
guard let qType = type else { return raw } //
return qType.displayAnswer(raw: raw, options: options)
}
}