From bac51224e217b38ddd46eff78b74c08c77dbfd0c Mon Sep 17 00:00:00 2001 From: wangdl Date: Sat, 30 May 2026 08:50:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(ios):=20M2=20=E6=B5=8B=E9=AA=8C=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E5=85=A8=E9=83=A8=204=20=E4=B8=AA=20issue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M2-01 测验列表页 (QuizListView): - 展示知识库下所有测验 + 空状态/生成新测验按钮 - POST /quizzes 生成测验 M2-02 答题页面 (QuizTakerView): - 选择题 ABCD 选项 / 判断题 ✓✗ / 填空题输入 - 进度条 + 上一题/下一题/提交按钮 - POST /quizzes/:id/start + POST /quizzes/:id/submit M2-03 结果页 (QuizResultView): - 得分展示 + 每题对错详情 + 解释 - 重新测验按钮 路由: quizList/quizTake/quizResult QuizService: generate/list/detail/start/submit/results/history Quiz 模型: Quiz/QuizQuestion/QuizAttempt/QuizAnswer/QuizSubmitRequest 等 Co-Authored-By: Claude Opus 4.7 --- .../AIStudyApp/Core/Models/APIModels.swift | 66 +++++ .../AIStudyApp/Core/Navigation/Route.swift | 8 + .../AIStudyApp/Core/Services/APIService.swift | 27 ++ .../AIStudyApp/Features/Quiz/QuizViews.swift | 242 ++++++++++++++++++ 4 files changed, 343 insertions(+) create mode 100644 AIStudyApp/AIStudyApp/Features/Quiz/QuizViews.swift diff --git a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift index 601d438..ef5ca52 100644 --- a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift +++ b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift @@ -397,6 +397,72 @@ struct BatchDeleteResponse: Codable { let count: Int? } +// MARK: - Quiz + +struct Quiz: Codable, Identifiable { + let id: String + let knowledgeBaseId: String? + let title: String? + let description: String? + let questionCount: Int? + let sourceType: String? + let status: String? + let createdAt: String? + let questions: [QuizQuestion]? +} + +struct QuizQuestion: Codable, Identifiable { + let id: String + let quizId: String? + let type: String? + let stem: String? + let options: [String]? + let answer: String? + let explanation: String? + let orderIndex: Int? +} + +struct QuizAttempt: Codable, Identifiable { + let id: String + let quizId: String? + let totalQuestions: Int? + let correctCount: Int? + let score: Int? + let startedAt: String? + let finishedAt: String? + let answers: [QuizAnswer]? + let quiz: QuizInfo? +} + +struct QuizInfo: Codable { let title: String? } + +struct QuizAnswer: Codable, Identifiable { + let id: String + let attemptId: String? + let questionId: String? + let userAnswer: String? + let isCorrect: Bool? + let answeredAt: String? + let question: QuizQuestion? +} + +struct QuizSubmitRequest: Codable { + let attemptId: String + let answers: [QuizAnswerItem] +} + +struct QuizAnswerItem: Codable { + let questionId: String + let answer: String +} + +struct QuizSubmitResponse: Codable { + let score: Int? + let correctCount: Int? + let totalQuestions: Int? + let finishedAt: String? +} + // MARK: - RAG Chat struct ChatSession: Codable, Identifiable { diff --git a/AIStudyApp/AIStudyApp/Core/Navigation/Route.swift b/AIStudyApp/AIStudyApp/Core/Navigation/Route.swift index 9ff3ca4..ed7cf70 100644 --- a/AIStudyApp/AIStudyApp/Core/Navigation/Route.swift +++ b/AIStudyApp/AIStudyApp/Core/Navigation/Route.swift @@ -25,6 +25,11 @@ enum Route: Hashable { // Import case importReview(sourceId: String) + // Quiz + case quizList(knowledgeBaseId: String) + case quizTake(quizId: String) + case quizResult(quizId: String, attemptId: String) + // Profile case notificationList case settings @@ -64,6 +69,9 @@ extension Route { case .feedbackForm: FeedbackFormView() case .editProfile: EditProfilePage() case .importReview(let sourceId): ImportReviewPage(sourceId: sourceId) + case .quizList(let kbId): QuizListView(knowledgeBaseId: kbId) + case .quizTake(let id): QuizTakerView(quizId: id) + case .quizResult(let qid, let aid): QuizResultView(quizId: qid, attemptId: aid) } } } diff --git a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift index 4aa2712..0608133 100644 --- a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift +++ b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift @@ -445,6 +445,33 @@ class KnowledgeSourceService { } } +// MARK: - Quiz + +@MainActor +class QuizService { + static let shared = QuizService() + private let client = APIClient.shared + + func generate(knowledgeBaseId: String, questionCount: Int = 5) async throws -> Quiz { + return try await client.request("/quizzes", method: "POST", body: ["knowledgeBaseId": knowledgeBaseId, "questionCount": questionCount] as [String: Any]) + } + + func list(knowledgeBaseId: String? = nil) async throws -> [Quiz] { + var items: [URLQueryItem]? = nil + if let kb = knowledgeBaseId { items = [URLQueryItem(name: "knowledgeBaseId", value: kb)] } + return try await client.request("/quizzes", queryItems: items) + } + + func detail(id: String) async throws -> Quiz { return try await client.request("/quizzes/\(id)") } + func start(quizId: String) async throws -> QuizAttempt { return try await client.request("/quizzes/\(quizId)/start", method: "POST") } + func submit(quizId: String, attemptId: String, answers: [QuizAnswerItem]) async throws -> QuizSubmitResponse { + let body = QuizSubmitRequest(attemptId: attemptId, answers: answers) + return try await client.request("/quizzes/\(quizId)/submit", method: "POST", body: body) + } + func results(quizId: String, attemptId: String) async throws -> QuizAttempt { return try await client.request("/quizzes/\(quizId)/results?attemptId=\(attemptId)") } + func history() async throws -> [QuizAttempt] { return try await client.request("/quizzes/history/list") } +} + // MARK: - Notifications @MainActor diff --git a/AIStudyApp/AIStudyApp/Features/Quiz/QuizViews.swift b/AIStudyApp/AIStudyApp/Features/Quiz/QuizViews.swift new file mode 100644 index 0000000..f1b066e --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Quiz/QuizViews.swift @@ -0,0 +1,242 @@ +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: 13)).foregroundColor(Color.zxF04) }.frame(maxWidth: .infinity, maxHeight: .infinity) + } else if quizzes.isEmpty { + VStack(spacing: 16) { + Image(systemName: "questionmark.circle").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(systemName: "plus"); 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: 15, weight: .semibold)).foregroundColor(Color.zxF0); Spacer(); Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(Color.zxF03) } + HStack(spacing: 12) { + Label("\(q.questionCount ?? 0) 题", systemImage: "list.bullet").font(.system(size: 11)).foregroundColor(Color.zxF04) + Label("选择题/判断/填空", systemImage: "square.grid.3x3").font(.system(size: 11)).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).animatedTabBarHide().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 + +struct QuizTakerView: View { + let quizId: String + @State private var quiz: Quiz? + @State private var attempt: QuizAttempt? + @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 = "" + + var body: some View { + ZStack { + Color.zxBg0.ignoresSafeArea() + if isLoading { + VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载测验…").font(.system(size: 13)).foregroundColor(Color.zxF04) }.frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let q = quiz, let questions = q.questions, !questions.isEmpty { + VStack(spacing: 0) { + // Progress + VStack(spacing: 8) { + HStack { + Text("第 \(currentIndex + 1) / \(questions.count) 题").font(.system(size: 13, 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) + + if question.type == "choice", let options = question.options { + VStack(spacing: 8) { + ForEach(Array(options.enumerated()), id: \.offset) { i, opt in + Button { + answers[question.id] = String(i) + } label: { + HStack(spacing: 12) { + Text(["A","B","C","D"][i]).font(.system(size: 13, 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) + } + } + } else if question.type == "judge" { + HStack(spacing: 12) { + ForEach(["true", "false"], id: \.self) { v in + Button { + answers[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) + } + } + } else { + TextField("输入你的答案", text: Binding(get: { answers[question.id] ?? "" }, set: { answers[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)) + } + }.padding(.horizontal, 20) + } + } + + // Navigation buttons + HStack { + Button { if currentIndex > 0 { currentIndex -= 1 } } label: { + HStack { Image(systemName: "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(systemName: "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).animatedTabBarHide().toolbarBackground(.hidden, for: .navigationBar) + .task { await load() } + } + + private func load() async { + isLoading = true + do { + quiz = try await QuizService.shared.detail(id: quizId) + attempt = try await QuizService.shared.start(quizId: quizId) + } catch {} + isLoading = false + } + + private func submit() async { + isSubmitting = true + guard let att = attempt else { return } + let items = answers.map { QuizAnswerItem(questionId: $0.key, answer: $0.value) } + do { + let _ = try await QuizService.shared.submit(quizId: quizId, attemptId: att.id, answers: items) + resultAttemptId = att.id + showResult = true + } catch { ZXToastManager.shared.error("提交失败") } + isSubmitting = false + } +} + +// MARK: - Quiz Result + +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: 13)).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: 15)).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: 15, weight: .bold)).foregroundColor(Color.zxF0) + ForEach(r.answers ?? []) { a in + HStack(spacing: 12) { + Image(systemName: a.isCorrect == true ? "checkmark.circle.fill" : "xmark.circle.fill").font(.system(size: 18)).foregroundColor(a.isCorrect == true ? Color.zxGreen : Color.zxCoral) + VStack(alignment: .leading, spacing: 4) { + Text(a.question?.stem ?? "").font(.system(size: 13)).foregroundColor(Color.zxF0).lineLimit(2) + if let exp = a.question?.explanation { Text(exp).font(.system(size: 11)).foregroundColor(Color.zxF04).lineLimit(2) } + } + Spacer() + }.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).animatedTabBarHide().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 + } +}