- 所有 SF Symbol .fill 图标替换为线性版本 - 自定义加载动画全部替换为原生 ProgressView/refreshable - StudyHomeView 重设计:优先级驱动主行动卡片 - ZLibraryCard 重新设计:封面图自适应、信息布局优化 - LibraryDetailPage:顶部KB信息区、···菜单、排序、长按操作 - 知识点列表:文件类型图标、学习时长、分割线样式 - 弥散渐变顶部背景 - 新增 icon-folder、icon-xmark SVG Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
222 lines
9.6 KiB
Swift
222 lines
9.6 KiB
Swift
import SwiftUI
|
||
|
||
struct ActiveRecallView: View {
|
||
@StateObject private var viewModel = ActiveRecallViewModel()
|
||
let questions: [RecallQuestion] = [
|
||
.init(id: "1", question: "请解释贝叶斯定理的核心思想,并写出公式", source: "机器学习 · 概率论", isVoice: false),
|
||
.init(id: "2", question: "请用自己的话解释梯度下降算法的工作原理", source: "机器学习 · 优化算法", isVoice: false),
|
||
.init(id: "3", question: "用费曼学习法解释「过拟合与欠拟合」的区别", source: "机器学习 · 模型选择", isVoice: true),
|
||
]
|
||
|
||
@State private var idx = 0
|
||
@State private var answers: [String: String] = [:]
|
||
@State private var currentAnswer = ""
|
||
@State private var submitted: Set<String> = []
|
||
@State private var showFinish = false
|
||
@State private var showThinking = false
|
||
@State private var showCelebration = false
|
||
|
||
var current: RecallQuestion { questions[idx] }
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
Color.zxBg0.ignoresSafeArea()
|
||
VStack(spacing: 0) {
|
||
progressHeader
|
||
ScrollView {
|
||
VStack(spacing: 16) {
|
||
questionCard
|
||
if !isSubmitted {
|
||
answerInput
|
||
} else {
|
||
submittedView
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.top, 12)
|
||
.padding(.bottom, 120)
|
||
}
|
||
.scrollIndicators(.hidden)
|
||
}
|
||
}
|
||
.navigationBarTitleDisplayMode(.inline).toolbar(.hidden, for: .tabBar)
|
||
.toolbarBackground(.hidden, for: .navigationBar)
|
||
.task { await viewModel.loadQuestions() }
|
||
.overlay {
|
||
if showThinking {
|
||
ZXThinkingOverlay("AI 正在分析你的回答…")
|
||
}
|
||
}
|
||
.overlay {
|
||
if showCelebration {
|
||
ZXCelebrationView(title: "回忆完成", subtitle: "你已完成所有主动回忆题目,AI 分析结果已生成") {
|
||
withAnimation(.easeOut(duration: 0.3)) { showCelebration = false }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var isSubmitted: Bool { submitted.contains(current.id) }
|
||
|
||
private var progressHeader: some View {
|
||
VStack(spacing: 8) {
|
||
HStack {
|
||
Text("主动回忆 \(idx + 1)/\(questions.count)")
|
||
.font(.system(size: 12, weight: .medium))
|
||
.foregroundColor(Color.zxF04)
|
||
Spacer()
|
||
Text("已答 \(submitted.count)")
|
||
.font(.system(size: 12, weight: .medium))
|
||
.foregroundColor(Color.zxPurple)
|
||
}
|
||
ZStack(alignment: .leading) {
|
||
RoundedRectangle(cornerRadius: 2).fill(Color.zxFill008).frame(height: 3)
|
||
RoundedRectangle(cornerRadius: 2)
|
||
.fill(ZXGradient.progressBar)
|
||
.frame(width: max(3, CGFloat(idx + 1) / CGFloat(questions.count) * (UIScreen.main.bounds.width - 40)), height: 3)
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.top, 8)
|
||
.padding(.bottom, 4)
|
||
}
|
||
|
||
private var questionCard: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack(spacing: 8) {
|
||
if current.isVoice {
|
||
Image("icon-mic").font(.system(size: 12)).foregroundColor(Color.zxOrange)
|
||
Text("语音题").font(.system(size: 10, weight: .bold)).foregroundColor(Color.zxOrange)
|
||
.padding(.horizontal, 6).padding(.vertical, 2).background(Color.zxOrangeBG(0.1)).clipShape(Capsule())
|
||
} else {
|
||
Image("icon-pencil").font(.system(size: 12)).foregroundColor(Color.zxPurple)
|
||
Text("文字题").font(.system(size: 10, weight: .bold)).foregroundColor(Color.zxPurple)
|
||
.padding(.horizontal, 6).padding(.vertical, 2).background(Color.zxPurpleBG(0.1)).clipShape(Capsule())
|
||
}
|
||
Spacer()
|
||
Text(current.source).font(.system(size: 10)).foregroundColor(Color.zxF03)
|
||
}
|
||
Text(current.question)
|
||
.font(.system(size: 16, weight: .semibold))
|
||
.foregroundColor(Color.zxF0)
|
||
.lineSpacing(5)
|
||
}
|
||
.padding(16)
|
||
.background(ZXGradient.thinkingCard)
|
||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1))
|
||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||
.accessibilityElement(children: .combine)
|
||
.accessibilityLabel("问题 \(idx + 1):\(current.question)")
|
||
.accessibilityHint(current.isVoice ? "语音题,双击录音回答" : "文字题,在下方输入回答")
|
||
}
|
||
|
||
private var answerInput: some View {
|
||
VStack(spacing: 16) {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("你的回答").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035)
|
||
if current.isVoice {
|
||
voiceInputArea
|
||
} else {
|
||
TextEditor(text: $currentAnswer)
|
||
.font(.system(size: 14))
|
||
.foregroundColor(Color.zxF0)
|
||
.tint(Color.zxPurple)
|
||
.frame(minHeight: 150)
|
||
.scrollContentBackground(.hidden)
|
||
.padding(12)
|
||
.background(Color.zxFill004)
|
||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
|
||
}
|
||
}
|
||
Button {
|
||
answers[current.id] = current.isVoice ? "语音答案已录制" : currentAnswer
|
||
submitted.insert(current.id)
|
||
currentAnswer = ""
|
||
if submitted.count == questions.count {
|
||
showThinking = true
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||
showThinking = false
|
||
showCelebration = true
|
||
}
|
||
}
|
||
} label: {
|
||
Text("提交回答")
|
||
.font(.system(size: 14, weight: .bold))
|
||
.foregroundColor(.white)
|
||
.frame(maxWidth: .infinity).frame(height: 52)
|
||
.background(ZXGradient.ctaPurple)
|
||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||
}
|
||
.zxPressable()
|
||
.disabled(currentAnswer.isEmpty && !current.isVoice)
|
||
.opacity(currentAnswer.isEmpty && !current.isVoice ? 0.5 : 1)
|
||
.accessibilityLabel("提交回答")
|
||
.accessibilityHint("提交后可由 AI 分析你的回答质量")
|
||
}
|
||
}
|
||
|
||
private var voiceInputArea: some View {
|
||
VStack(spacing: 12) {
|
||
ZStack {
|
||
Circle().fill(Color.zxOrangeBG(0.1)).frame(width: 80, height: 80)
|
||
Image("icon-mic").font(.system(size: 32)).foregroundColor(Color.zxOrange)
|
||
}
|
||
Text("点击按钮开始录音,用费曼方法口头解释").font(.system(size: 12)).foregroundColor(Color.zxF04)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 24)
|
||
.background(Color.zxFill004)
|
||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
|
||
}
|
||
|
||
private var submittedView: some View {
|
||
VStack(spacing: 16) {
|
||
HStack(spacing: 10) {
|
||
Image(systemName: "checkmark.circle").font(.system(size: 22)).foregroundColor(Color.zxGreen)
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text("回答已提交").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxGreen)
|
||
Text("AI 分析中,稍后可查看反馈").font(.system(size: 12)).foregroundColor(Color.zxF04)
|
||
}
|
||
Spacer()
|
||
}
|
||
.padding(16)
|
||
.background(Color.zxGreenBG(0.06))
|
||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color(hex: "#34D399", opacity: 0.15), lineWidth: 1))
|
||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||
|
||
if idx < questions.count - 1 {
|
||
Button { idx += 1 } label: {
|
||
Label("下一题", systemImage: "arrow.right")
|
||
.font(.system(size: 14, weight: .bold))
|
||
.foregroundColor(.white)
|
||
.frame(maxWidth: .infinity).frame(height: 52)
|
||
.background(ZXGradient.brand)
|
||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||
}
|
||
.zxPressable()
|
||
} else {
|
||
Button {
|
||
showCelebration = true
|
||
} label: {
|
||
Label("查看 AI 分析结果", systemImage: "sparkles")
|
||
.font(.system(size: 14, weight: .bold))
|
||
.foregroundColor(.white)
|
||
.frame(maxWidth: .infinity).frame(height: 52)
|
||
.background(ZXGradient.ctaPurple)
|
||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||
}
|
||
.zxPressable()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
struct RecallQuestion: Identifiable {
|
||
let id: String
|
||
let question: String
|
||
let source: String
|
||
let isVoice: Bool
|
||
}
|