339 lines
20 KiB
Swift
339 lines
20 KiB
Swift
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("基于资料内容 AI 生成测验题目").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))
|
||
default:
|
||
// 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
|
||
AnswerDetailRow(a: a)
|
||
.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 统一展示
|
||
fileprivate struct AnswerDetailRow: View {
|
||
let a: QuizAttemptAnswer
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack(spacing: 12) {
|
||
let correct = a.isCorrect == true
|
||
let stem = a.question?.stem ?? ""
|
||
Image(systemName: correct ? "checkmark.circle" : "xmark.circle").font(.system(size: 18)).foregroundColor(correct ? Color.zxGreen : Color.zxCoral)
|
||
Text(stem).font(.system(size: 14)).foregroundColor(Color.zxF0).lineLimit(3)
|
||
Spacer()
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
static func answerDisplayText(raw: String, type: String?, options: [String]) -> String {
|
||
guard let t = type, let qType = QuizQuestionType(rawValue: t) else { return raw }
|
||
return qType.displayAnswer(raw: raw, options: options)
|
||
}
|
||
}
|
||
|
||
static func answerDisplayText(raw: String, type: QuizQuestionType?, options: [String]) -> String {
|
||
guard let qType = type else { return raw } // 未知题型保留原始值
|
||
return qType.displayAnswer(raw: raw, options: options)
|
||
}
|
||
}
|