diff --git a/AIStudyApp/AIStudyApp.entitlements b/AIStudyApp/AIStudyApp.entitlements
new file mode 100644
index 0000000..a812db5
--- /dev/null
+++ b/AIStudyApp/AIStudyApp.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.developer.applesignin
+
+ Default
+
+
+
diff --git a/AIStudyApp/AIStudyApp.xcodeproj/project.pbxproj b/AIStudyApp/AIStudyApp.xcodeproj/project.pbxproj
index 5fab4b7..d4acc6a 100644
--- a/AIStudyApp/AIStudyApp.xcodeproj/project.pbxproj
+++ b/AIStudyApp/AIStudyApp.xcodeproj/project.pbxproj
@@ -247,6 +247,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = AIStudyApp.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 88FMP9VK6T;
@@ -282,6 +283,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = AIStudyApp.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 88FMP9VK6T;
diff --git a/AIStudyApp/AIStudyApp/AIStudyAppApp.swift b/AIStudyApp/AIStudyApp/AIStudyAppApp.swift
index 6c55edd..7c7f577 100644
--- a/AIStudyApp/AIStudyApp/AIStudyAppApp.swift
+++ b/AIStudyApp/AIStudyApp/AIStudyAppApp.swift
@@ -1,9 +1,11 @@
import SwiftUI
+import AuthenticationServices
@main
struct AIStudyAppApp: App {
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
@AppStorage("appAppearance") private var appAppearance = "system"
+ @StateObject private var authManager = AuthManager()
private var effectiveColorScheme: ColorScheme? {
switch appAppearance {
@@ -15,102 +17,251 @@ struct AIStudyAppApp: App {
var body: some Scene {
WindowGroup {
- if hasCompletedOnboarding {
- ContentView().preferredColorScheme(effectiveColorScheme)
- } else {
- OnboardingFlowView(hasCompletedOnboarding: $hasCompletedOnboarding)
- .preferredColorScheme(effectiveColorScheme)
+ Group {
+ if authManager.isRestoring {
+ SplashScreen()
+ } else if authManager.isAuthenticated {
+ if hasCompletedOnboarding {
+ ContentView()
+ .environmentObject(authManager)
+ } else {
+ PostLoginOnboardingFlow(hasCompletedOnboarding: $hasCompletedOnboarding)
+ .environmentObject(authManager)
+ }
+ } else {
+ PreLoginFlow()
+ .environmentObject(authManager)
+ }
+ }
+ .preferredColorScheme(effectiveColorScheme)
+ .task {
+ await authManager.restoreSession()
}
}
}
}
-struct OnboardingFlowView: View {
+// MARK: - Splash (session restore)
+
+struct SplashScreen: View {
+ var body: some View {
+ ZStack {
+ LinearGradient(colors: [Color(hex: "#0D0D20"), Color(hex: "#0F0F1A"), Color(hex: "#130D20")], startPoint: .top, endPoint: .bottom).ignoresSafeArea()
+ Circle().fill(RadialGradient(colors: [Color(hex: "#7C6EFA", opacity: 0.25), .clear], center: .center, startRadius: 0, endRadius: 140)).frame(width: 280, height: 280).offset(y: -60).allowsHitTesting(false)
+ VStack(spacing: 0) {
+ RoundedRectangle(cornerRadius: 28).fill(LinearGradient(colors: [Color(hex: "#7C6EFA"), Color(hex: "#A78BFA"), Color(hex: "#F97316")], startPoint: .topLeading, endPoint: .bottomTrailing)).frame(width: 96, height: 96).overlay(Image(systemName: "brain.head.profile").font(.system(size: 44)).foregroundColor(.white.opacity(0.8))).shadow(color: Color(hex: "#7C6EFA", opacity: 0.5), radius: 40).padding(.bottom, 24)
+ Text("知习").font(.system(size: 36, weight: .heavy)).tracking(-1).foregroundStyle(LinearGradient(colors: [Color(hex: "#A78BFA"), Color(hex: "#F0F0FF"), Color(hex: "#F97316")], startPoint: .leading, endPoint: .trailing))
+ ProgressView().tint(Color(hex: "#F0F0FF", opacity: 0.5)).padding(.top, 32)
+ }
+ }
+ }
+}
+
+// MARK: - Pre-login flow (Welcome → Login)
+
+struct PreLoginFlow: View {
+ @EnvironmentObject var authManager: AuthManager
+ @State private var step = 0
+
+ var body: some View {
+ ZStack {
+ switch step {
+ case 0:
+ WelcomePage(onContinue: { withAnimation(.easeInOut(duration: 0.5)) { step = 1 } })
+ case 1:
+ LoginPage()
+ default:
+ EmptyView()
+ }
+ }
+ }
+}
+
+// MARK: - Post-login onboarding (Onboarding → GoalSetup)
+
+struct PostLoginOnboardingFlow: View {
@Binding var hasCompletedOnboarding: Bool
@State private var step = 0
var body: some View {
ZStack {
switch step {
- case 0: SplashPage { withAnimation(.easeInOut(duration: 0.5)) { step = 1 } }
- case 1: WelcomePage { withAnimation { step = 2 } } onSkip: { hasCompletedOnboarding = true }
- case 2: LoginPage { step = 3 } onSkip: { hasCompletedOnboarding = true }
- case 3: OnboardingPage { step = 4 }
- case 4: GoalSetupPage { $0 ? (hasCompletedOnboarding = true) : (step = 0) }
- default: EmptyView()
+ case 0:
+ OnboardingPage { withAnimation { step = 1 } }
+ case 1:
+ GoalSetupPage { _ in hasCompletedOnboarding = true }
+ default:
+ EmptyView()
}
}
}
}
-// Splash
-struct SplashPage: View {
- let onFinish: () -> Void
+// MARK: - Welcome
+
+struct WelcomePage: View {
+ let onContinue: () -> Void
+
var body: some View {
ZStack {
- LinearGradient(colors: [Color(hex: "#0D0D20"), Color(hex: "#0F0F1A"), Color(hex: "#130D20")], startPoint: .top, endPoint: .bottom).ignoresSafeArea()
- Circle().fill(RadialGradient(colors: [Color(hex: "#7C6EFA", opacity: 0.25), .clear], center: .center, startRadius: 0, endRadius: 140)).frame(width: 280, height: 280).offset(y: -60).allowsHitTesting(false)
- Circle().fill(RadialGradient(colors: [Color(hex: "#F97316", opacity: 0.15), .clear], center: .center, startRadius: 0, endRadius: 100)).frame(width: 200, height: 200).offset(y: 180).allowsHitTesting(false)
- VStack(spacing: 0) {
- RoundedRectangle(cornerRadius: 28).fill(LinearGradient(colors: [Color(hex: "#7C6EFA"), Color(hex: "#A78BFA"), Color(hex: "#F97316")], startPoint: .topLeading, endPoint: .bottomTrailing)).frame(width: 96, height: 96).overlay(Image(systemName: "brain.head.profile").font(.system(size: 44)).foregroundColor(.white.opacity(0.8))).shadow(color: Color(hex: "#7C6EFA", opacity: 0.5), radius: 40).padding(.bottom, 24)
- Text("知习").font(.system(size: 36, weight: .heavy)).tracking(-1).foregroundStyle(LinearGradient(colors: [Color(hex: "#A78BFA"), Color(hex: "#F0F0FF"), Color(hex: "#F97316")], startPoint: .leading, endPoint: .trailing))
- Text("Z H I X I").font(.system(size: 13, weight: .medium)).foregroundColor(Color(hex: "#F0F0FF", opacity: 0.4)).tracking(3).padding(.top, 6)
- Text("AI-first 系统化学习").font(.system(size: 14)).foregroundColor(Color(hex: "#F0F0FF", opacity: 0.45)).tracking(0.5).padding(.top, 24)
+ ZXGradient.page.ignoresSafeArea()
+ Circle().fill(RadialGradient(colors: [Color(hex: "#7C6EFA", opacity: 0.12), .clear], center: .topTrailing, startRadius: 0, endRadius: 260)).frame(width: 260, height: 260).offset(x: 80, y: -120).allowsHitTesting(false)
+ VStack { Spacer()
+ VStack(spacing: 14) {
+ HStack(spacing: 6) { Image(systemName: "sparkles").font(.system(size: 12)); Text("AI 驱动").font(.system(size: 12, weight: .semibold)) }.foregroundColor(Color.zxAccent).padding(.horizontal, 12).padding(.vertical, 6).background(Color(hex: "#7C6EFA", opacity: 0.1)).clipShape(Capsule())
+ Text("用 AI 重新定义\n你的学习方式").font(.system(size: 32, weight: .heavy)).tracking(-0.8).lineSpacing(4)
+ VStack(spacing: 10) {
+ FeatureRow(icon: "🧠", title: "主动回忆", desc: "基于间隔重复的智能复习")
+ FeatureRow(icon: "🎤", title: "费曼解释", desc: "用自己的话讲出来")
+ FeatureRow(icon: "📊", title: "AI 分析", desc: "发现知识薄弱点")
+ }
}
- VStack { Spacer(); ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 2).fill(Color(hex: "#FFFFFF", opacity: 0.1)).frame(width: 40, height: 3); RoundedRectangle(cornerRadius: 2).fill(LinearGradient(colors: [Color.zxPurple, Color.zxOrange], startPoint: .leading, endPoint: .trailing)).frame(width: 24, height: 3) }.padding(.bottom, 80) }
- }.onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { onFinish() } }
+ VStack(spacing: 12) {
+ Button { onContinue() } label: {
+ Text("开始使用").font(.system(size: 16, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 56).background(ZXGradient.ctaButton).clipShape(RoundedRectangle(cornerRadius: 18)).shadow(color: Color(hex: "#7C6EFA", opacity: 0.4), radius: 20)
+ }
+ }.padding(.bottom, 32)
+ }.padding(.horizontal, 20)
+ }
+ }
+ }
+
+struct FeatureRow: View {
+ let icon: String; let title: String; let desc: String
+ var body: some View {
+ HStack(spacing: 14) {
+ Text(icon).font(.system(size: 20)).frame(width: 40, height: 40).background(Color(hex: "#7C6EFA", opacity: 0.1)).clipShape(RoundedRectangle(cornerRadius: 12))
+ VStack(alignment: .leading, spacing: 2) { Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF04) }
+ }.padding(.horizontal, 16).padding(.vertical, 14).background(Color.zxFill003).overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 16))
}
}
-// Welcome
-struct WelcomePage: View { let onContinue: () -> Void; let onSkip: () -> Void
- var body: some View { ZStack { ZXGradient.page.ignoresSafeArea(); Circle().fill(RadialGradient(colors: [Color(hex: "#7C6EFA", opacity: 0.12), .clear], center: .topTrailing, startRadius: 0, endRadius: 260)).frame(width: 260, height: 260).offset(x: 80, y: -120).allowsHitTesting(false)
- VStack { Spacer()
- VStack(spacing: 14) { HStack(spacing: 6) { Image(systemName: "sparkles").font(.system(size: 12)); Text("AI 驱动").font(.system(size: 12, weight: .semibold)) }.foregroundColor(Color.zxAccent).padding(.horizontal, 12).padding(.vertical, 6).background(Color(hex: "#7C6EFA", opacity: 0.1)).clipShape(Capsule())
- Text("用 AI 重新定义\n你的学习方式").font(.system(size: 32, weight: .heavy)).tracking(-0.8).lineSpacing(4)
- VStack(spacing: 10) { FeatureRow(icon: "🧠", title: "主动回忆", desc: "基于间隔重复的智能复习"); FeatureRow(icon: "🎤", title: "费曼解释", desc: "用自己的话讲出来"); FeatureRow(icon: "📊", title: "AI 分析", desc: "发现知识薄弱点") } }
- VStack(spacing: 12) { Button { onContinue() } label: { Text("开始使用").font(.system(size: 16, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 56).background(ZXGradient.ctaButton).clipShape(RoundedRectangle(cornerRadius: 18)).shadow(color: Color(hex: "#7C6EFA", opacity: 0.4), radius: 20) }; Button { onSkip() } label: { Text("已有账号?立即登录").font(.system(size: 14, weight: .medium)).foregroundColor(Color(hex: "#F0F0FF", opacity: 0.7)) }.padding(.bottom, 32) } }.padding(.horizontal, 20) } }
-}
-struct FeatureRow: View { let icon: String; let title: String; let desc: String
- var body: some View { HStack(spacing: 14) { Text(icon).font(.system(size: 20)).frame(width: 40, height: 40).background(Color(hex: "#7C6EFA", opacity: 0.1)).clipShape(RoundedRectangle(cornerRadius: 12)); VStack(alignment: .leading, spacing: 2) { Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF04) } }.padding(.horizontal, 16).padding(.vertical, 14).background(Color.zxFill003).overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 16)) }
+// MARK: - Login
+
+struct LoginPage: View {
+ @EnvironmentObject var authManager: AuthManager
+ @State private var isLoggingIn = false
+ @State private var errorMessage: String?
+
+ var body: some View {
+ ZStack {
+ ZXGradient.page.ignoresSafeArea()
+ Circle().fill(RadialGradient(colors: [Color(hex: "#7C6EFA", opacity: 0.15), .clear], center: .top, startRadius: 0, endRadius: 300)).frame(width: 300, height: 300).offset(y: -80).allowsHitTesting(false)
+
+ VStack { Spacer()
+ VStack(spacing: 32) {
+ RoundedRectangle(cornerRadius: 28).fill(LinearGradient(colors: [Color(hex: "#7C6EFA"), Color(hex: "#A78BFA"), Color(hex: "#F97316")], startPoint: .topLeading, endPoint: .bottomTrailing)).frame(width: 80, height: 80).overlay(Image(systemName: "brain.head.profile").font(.system(size: 36)).foregroundColor(.white.opacity(0.8))).shadow(color: Color(hex: "#7C6EFA", opacity: 0.3), radius: 30).padding(.bottom, -4)
+
+ VStack(spacing: 8) {
+ Text("欢迎使用知习").font(.system(size: 26, weight: .heavy)).tracking(-0.6)
+ Text("使用 Apple 账号登录以同步学习数据").font(.system(size: 14)).foregroundColor(Color.zxF04)
+ }
+
+ if let error = errorMessage {
+ Text(error).font(.system(size: 13)).foregroundColor(.red)
+ .padding(.horizontal, 16).padding(.vertical, 10)
+ .background(Color.red.opacity(0.1))
+ .clipShape(RoundedRectangle(cornerRadius: 10))
+ }
+
+ SignInWithAppleButton(.signIn) { request in
+ request.requestedScopes = [.fullName, .email]
+ } onCompletion: { result in
+ handleAppleResult(result)
+ }
+ .signInWithAppleButtonStyle(.white)
+ .frame(height: 54)
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+ .disabled(isLoggingIn)
+ .overlay {
+ if isLoggingIn {
+ ProgressView().tint(.white)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.black.opacity(0.5))
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+ }
+ }
+ }.padding(.horizontal, 24).padding(.bottom, 48)
+ } }
+ }
+
+ private func handleAppleResult(_ result: Result) {
+ switch result {
+ case .success(let auth):
+ guard let credential = auth.credential as? ASAuthorizationAppleIDCredential,
+ let identityToken = credential.identityToken,
+ let tokenStr = String(data: identityToken, encoding: .utf8) else {
+ errorMessage = "获取 Apple 身份信息失败"
+ return
+ }
+ let givenName = credential.fullName?.givenName
+ let familyName = credential.fullName?.familyName
+
+ isLoggingIn = true
+ errorMessage = nil
+ Task {
+ do {
+ let resp = try await AuthService.shared.appleLogin(
+ identityToken: tokenStr,
+ givenName: givenName,
+ familyName: familyName
+ )
+ await authManager.signIn(resp)
+ isLoggingIn = false
+ } catch {
+ isLoggingIn = false
+ errorMessage = "登录失败: \(error.localizedDescription)"
+ }
+ }
+
+ case .failure(let error):
+ if (error as NSError).code != ASAuthorizationError.canceled.rawValue {
+ errorMessage = "Apple 登录失败: \(error.localizedDescription)"
+ }
+ }
+ }
}
-// Login
-struct LoginPage: View { let onContinue: () -> Void; let onSkip: () -> Void
- @State private var isEmail = false; @State private var phone = ""; @State private var email = ""; @State private var pw = ""; @State private var showPw = false
- var body: some View { ZStack { Color.zxBg0.ignoresSafeArea(); Circle().fill(RadialGradient(colors: [Color(hex: "#7C6EFA", opacity: 0.1), .clear], center: .top, startRadius: 0, endRadius: 200)).frame(width: 200, height: 200).offset(y: -60).allowsHitTesting(false)
- VStack { Spacer()
- VStack(spacing: 24) { VStack(spacing: 6) { Text("欢迎登录").font(.system(size: 28, weight: .heavy)).tracking(-0.6); Text("使用手机号或邮箱登录").font(.system(size: 14)).foregroundColor(Color.zxF05) }; HStack(spacing: 4) { ZXTabBtn(t: "手机号", active: !isEmail) { isEmail = false }; ZXTabBtn(t: "邮箱", active: isEmail) { isEmail = true } }.padding(4).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 12))
- if isEmail { VStack(alignment: .leading, spacing: 8) { Text("邮箱").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5); ZXInputField(placeholder: "your@email.com", text: $email) } }
- else { VStack(alignment: .leading, spacing: 8) { Text("手机号").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5); HStack(spacing: 0) { Text("+86").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0).padding(.trailing, 12).overlay(alignment: .trailing) { Rectangle().fill(Color.zxBorder01).frame(width: 1).padding(.vertical, 4) }.padding(.trailing, 12); TextField("手机号", text: $phone).keyboardType(.phonePad).font(.system(size: 15)).tint(Color.zxPurple) }.padding(.horizontal, 16).frame(height: 52).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 14)) } }
- ZXInputField(placeholder: "密码", text: $pw, isSecure: !showPw); HStack { Spacer(); Button { showPw.toggle() } label: { Image(systemName: showPw ? "eye" : "eye.slash").font(.system(size: 16)).foregroundColor(Color.zxF03) } }.padding(.trailing, 4)
- HStack { Spacer(); Button("忘记密码?") {}.font(.system(size: 13)).foregroundColor(Color.zxPurple) }
- Button { onContinue() } label: { Text("登录").font(.system(size: 16, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 56).background(ZXGradient.ctaButton).clipShape(RoundedRectangle(cornerRadius: 18)).shadow(color: Color(hex: "#7C6EFA", opacity: 0.4), radius: 20) }
- HStack(spacing: 12) { Rectangle().fill(Color.zxBorder008).frame(height: 1); Text("或").font(.system(size: 12)).foregroundColor(Color.zxF03); Rectangle().fill(Color.zxBorder008).frame(height: 1) }
- HStack(spacing: 12) { SocialLoginBtn(emoji: "💬", text: "微信登陆", color: .green) {}; SocialLoginBtn(emoji: "🍎", text: "Apple 登录", color: .white) {} } }.padding(.horizontal, 20).padding(.bottom, 32) } } }
-}
+// MARK: - Shared UI components
+
struct ZXTabBtn: View { let t: String; let active: Bool; let a: () -> Void; var body: some View { Button(action: a) { Text(t).font(.system(size: 13, weight: .semibold)).foregroundColor(active ? .white : Color.zxF05).frame(maxWidth: .infinity).frame(height: 36).background(active ? AnyView(ZXGradient.brand) : AnyView(Color.clear)).clipShape(RoundedRectangle(cornerRadius: 9)) } } }
struct ZXInputField: View { let placeholder: String; @Binding var text: String; var isSecure = false; var body: some View { HStack { if isSecure { SecureField(placeholder, text: $text) } else { TextField(placeholder, text: $text) } }.font(.system(size: 15)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 52).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 14)) } }
struct SocialLoginBtn: View { let emoji: String; let text: String; let color: Color; let action: () -> Void; var body: some View { Button(action: action) { HStack(spacing: 10) { Text(emoji).font(.system(size: 18)); Text(text).font(.system(size: 11, weight: .medium)) }.foregroundColor(Color.zxF007).frame(maxWidth: .infinity).frame(height: 52).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 14)) } } }
-// Onboarding
-struct OnboardingPage: View { let onContinue: () -> Void; @State private var step = 0
+// MARK: - Onboarding
+
+struct OnboardingPage: View {
+ let onContinue: () -> Void
+ @State private var step = 0
let titles = ["输入知识", "主动输出", "AI 分析", "掌握知识"]
let descs = ["从任何地方收集并导入学习资料,构建你的专属知识库。", "通过间隔回忆和费曼解释法,将知识转化为长期记忆。", "AI 自动定位薄弱知识点,给出针对性的学习建议。", "系统性掌握每一个知识点,建立牢固的知识体系。"]
- var body: some View { ZStack { ZXGradient.page.ignoresSafeArea()
- VStack(spacing: 0) { Spacer()
- HStack(spacing: 6) { ForEach(0..<4, id: \.self) { i in RoundedRectangle(cornerRadius: 2).fill(i == step ? AnyShapeStyle(ZXGradient.brand) : AnyShapeStyle(Color(hex: "#FFFFFF", opacity: 0.1))).frame(width: i == step ? 24 : 8, height: 4) } }
- VStack(spacing: 12) { Text(titles[step]).font(.system(size: 24, weight: .heavy)).tracking(-0.5); Text(descs[step]).font(.system(size: 14)).foregroundColor(Color.zxF04).lineSpacing(4).multilineTextAlignment(.center) }.padding(.top, 32).padding(.bottom, 40)
- Button { if step < 3 { withAnimation { step += 1 } } else { onContinue() } } label: { Text(step < 3 ? "下一步" : "开始使用").font(.system(size: 16, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 56).background(ZXGradient.ctaButton).clipShape(RoundedRectangle(cornerRadius: 18)).shadow(color: Color(hex: "#7C6EFA", opacity: 0.4), radius: 20) }
- Button("跳过") { onContinue() }.font(.system(size: 12)).foregroundColor(Color.zxF03).padding(.top, 12).padding(.bottom, 32) } }.padding(.horizontal, 20) }
+
+ var body: some View {
+ ZStack {
+ ZXGradient.page.ignoresSafeArea()
+ VStack(spacing: 0) { Spacer()
+ HStack(spacing: 6) { ForEach(0..<4, id: \.self) { i in RoundedRectangle(cornerRadius: 2).fill(i == step ? AnyShapeStyle(ZXGradient.brand) : AnyShapeStyle(Color(hex: "#FFFFFF", opacity: 0.1))).frame(width: i == step ? 24 : 8, height: 4) } }
+ VStack(spacing: 12) { Text(titles[step]).font(.system(size: 24, weight: .heavy)).tracking(-0.5); Text(descs[step]).font(.system(size: 14)).foregroundColor(Color.zxF04).lineSpacing(4).multilineTextAlignment(.center) }.padding(.top, 32).padding(.bottom, 40)
+ Button { if step < 3 { withAnimation { step += 1 } } else { onContinue() } } label: { Text(step < 3 ? "下一步" : "开始使用").font(.system(size: 16, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 56).background(ZXGradient.ctaButton).clipShape(RoundedRectangle(cornerRadius: 18)).shadow(color: Color(hex: "#7C6EFA", opacity: 0.4), radius: 20) }
+ Button("跳过") { onContinue() }.font(.system(size: 12)).foregroundColor(Color.zxF03).padding(.top, 12).padding(.bottom, 32)
+ }.padding(.horizontal, 20)
+ }
+ }
}
-// GoalSetup
-struct GoalSetupPage: View { let onComplete: (Bool) -> Void
- @State private var selectedGoal = ""; let goals = [("🧑🎓","备考考试","公考、考研、考证等"),("💼","职业技能","编程、设计、产品等"),("📚","通识学习","扩充知识面"),("🎯","自定义","设定自己的目标")]
- @State private var selectedMethod = ""; let methods = ["间隔回忆","费曼技巧","AI 分析"]
- @State private var dailyMins = "30 分钟"; let times = ["15 分钟","30 分钟","1 小时","不限制"]
- var body: some View { ZStack { ZXGradient.page.ignoresSafeArea()
+// MARK: - GoalSetup
+
+struct GoalSetupPage: View {
+ let onComplete: (Bool) -> Void
+ @State private var selectedGoal = ""
+ let goals = [("🧑🎓","备考考试","公考、考研、考证等"),("💼","职业技能","编程、设计、产品等"),("📚","通识学习","扩充知识面"),("🎯","自定义","设定自己的目标")]
+ @State private var selectedMethod = ""
+ let methods = ["间隔回忆","费曼技巧","AI 分析"]
+ @State private var dailyMins = "30 分钟"
+ let times = ["15 分钟","30 分钟","1 小时","不限制"]
+
+ var body: some View {
+ ZStack { ZXGradient.page.ignoresSafeArea()
VStack(spacing: 0) { Spacer()
Text("设定你的学习目标").font(.system(size: 24, weight: .heavy)).tracking(-0.5).foregroundColor(Color.zxF0).padding(.bottom, 24)
ScrollView { VStack(spacing: 16) {
@@ -119,6 +270,8 @@ struct GoalSetupPage: View { let onComplete: (Bool) -> Void
VStack(alignment: .leading, spacing: 10) { Text("学习方法").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5)
HStack(spacing: 8) { ForEach(methods, id: \.self) { m in let sel = selectedMethod == m; Button { selectedMethod = m } label: { Text(m).font(.system(size: 13)).fontWeight(sel ? .semibold : .regular).foregroundColor(sel ? Color.zxPurple : Color.zxF05).padding(.horizontal, 16).padding(.vertical, 10).background(sel ? Color(hex: "#7C6EFA", opacity: 0.1) : Color.zxFill003).overlay(RoundedRectangle(cornerRadius: 20).stroke(sel ? Color(hex: "#7C6EFA", opacity: 0.25) : Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20)) }.foregroundColor(.primary) } } }
VStack(alignment: .leading, spacing: 10) { Text("每日学习时间").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5)
- HStack(spacing: 8) { ForEach(times, id: \.self) { t in let sel = dailyMins == t; Button { dailyMins = t } label: { Text(t).font(.system(size: 12)).fontWeight(sel ? .semibold : .regular).foregroundColor(sel ? Color.zxPurple : Color.zxF05).frame(maxWidth: .infinity).frame(height: 40).background(sel ? Color(hex: "#7C6EFA", opacity: 0.1) : Color.zxFill003).overlay(RoundedRectangle(cornerRadius: 12).stroke(sel ? Color(hex: "#7C6EFA", opacity: 0.25) : Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 12)) }.foregroundColor(.primary) } } } } }
- Button { onComplete(true) } label: { Text("开始学习").font(.system(size: 16, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 56).background(ZXGradient.ctaButton).clipShape(RoundedRectangle(cornerRadius: 18)).shadow(color: Color(hex: "#7C6EFA", opacity: 0.4), radius: 20) }.padding(.top, 24).padding(.bottom, 32).padding(.horizontal, 20) } } }
+ HStack(spacing: 8) { ForEach(times, id: \.self) { t in let sel = dailyMins == t; Button { dailyMins = t } label: { Text(t).font(.system(size: 12)).fontWeight(sel ? .semibold : .regular).foregroundColor(sel ? Color.zxPurple : Color.zxF05).frame(maxWidth: .infinity).frame(height: 40).background(sel ? Color(hex: "#7C6EFA", opacity: 0.1) : Color.zxFill003).overlay(RoundedRectangle(cornerRadius: 12).stroke(sel ? Color(hex: "#7C6EFA", opacity: 0.25) : Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 12)) }.foregroundColor(.primary) } } } }
+ Button { onComplete(true) } label: { Text("开始学习").font(.system(size: 16, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 56).background(ZXGradient.ctaButton).clipShape(RoundedRectangle(cornerRadius: 18)).shadow(color: Color(hex: "#7C6EFA", opacity: 0.4), radius: 20) }.padding(.top, 24).padding(.bottom, 32).padding(.horizontal, 20)
+ } } }
+ }
}
diff --git a/AIStudyApp/AIStudyApp/Core/Auth/AuthManager.swift b/AIStudyApp/AIStudyApp/Core/Auth/AuthManager.swift
new file mode 100644
index 0000000..2a50d35
--- /dev/null
+++ b/AIStudyApp/AIStudyApp/Core/Auth/AuthManager.swift
@@ -0,0 +1,103 @@
+import SwiftUI
+import Combine
+
+extension Notification.Name {
+ static let tokenExpired = Notification.Name("cloud.longde.AIStudyApp.tokenExpired")
+}
+
+@MainActor
+final class AuthManager: ObservableObject {
+ @Published var isAuthenticated = false
+ @Published var isRestoring = true
+
+ static let shared = AuthManager()
+
+ private var tokenExpiredObserver: NSObjectProtocol?
+
+ init() {
+ tokenExpiredObserver = NotificationCenter.default.addObserver(
+ forName: .tokenExpired, object: nil, queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor [weak self] in
+ self?.handleUnauthorized()
+ }
+ }
+ }
+
+ deinit {
+ if let observer = tokenExpiredObserver {
+ NotificationCenter.default.removeObserver(observer)
+ }
+ }
+
+ func restoreSession() async {
+ isRestoring = true
+ defer { isRestoring = false }
+
+ guard let token = KeychainHelper.getAccessToken() else {
+ isAuthenticated = false
+ return
+ }
+
+ await APIClient.shared.setToken(token)
+
+ do {
+ let _: UserProfileResponse = try await APIClient.shared.request("/users/me")
+ isAuthenticated = true
+ } catch {
+ if let refreshed = await tryRefresh() {
+ await APIClient.shared.setToken(refreshed.accessToken)
+ KeychainHelper.save(
+ accessToken: refreshed.accessToken,
+ refreshToken: refreshed.refreshToken,
+ userId: refreshed.user?.id ?? ""
+ )
+ isAuthenticated = true
+ } else {
+ await APIClient.shared.setToken(nil)
+ KeychainHelper.clear()
+ isAuthenticated = false
+ }
+ }
+ }
+
+ func signIn(_ response: AuthResponse) async {
+ await APIClient.shared.setToken(response.accessToken)
+ KeychainHelper.save(
+ accessToken: response.accessToken,
+ refreshToken: response.refreshToken,
+ userId: response.user?.id ?? ""
+ )
+ isAuthenticated = true
+ }
+
+ func signOut() async {
+ if let refreshToken = KeychainHelper.getRefreshToken() {
+ let body = RefreshRequest(refreshToken: refreshToken)
+ let _: GenericSuccessResponse? = try? await APIClient.shared.request(
+ "/auth/logout", method: "POST", body: body
+ )
+ }
+ await APIClient.shared.setToken(nil)
+ KeychainHelper.clear()
+ isAuthenticated = false
+ }
+
+ private func handleUnauthorized() {
+ Task {
+ await APIClient.shared.setToken(nil)
+ KeychainHelper.clear()
+ isAuthenticated = false
+ }
+ }
+
+ private func tryRefresh() async -> AuthResponse? {
+ guard let refreshToken = KeychainHelper.getRefreshToken() else { return nil }
+ do {
+ let body = RefreshRequest(refreshToken: refreshToken)
+ return try await APIClient.shared.request("/auth/refresh", method: "POST", body: body)
+ } catch {
+ return nil
+ }
+ }
+}
diff --git a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift
index efac9f2..b45dcef 100644
--- a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift
+++ b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift
@@ -1,20 +1,41 @@
//
-// Models.swift - 对应 api-server 的所有 DTO
+// APIModels.swift - 对齐 api-server 实际返回结构
//
import Foundation
+// MARK: - API Envelope (ResponseInterceptor wraps all responses)
+
+struct APIEnvelope: Decodable {
+ let success: Bool
+ let data: T
+ let timestamp: String?
+}
+
+// MARK: - Pagination
+
+struct PaginationMeta: Codable {
+ let page: Int
+ let limit: Int
+ let total: Int
+}
+
+struct PaginatedResponse: Decodable {
+ let data: [T]
+ let meta: PaginationMeta
+}
+
// MARK: - Waitlist
struct WaitlistEntry: Codable, Identifiable {
let id: String
- let email: String
let nickname: String?
+ let email: String
let devices: [String]?
let interests: [String]?
let painpoint: String?
let willingBeta: Bool?
- let createdAt: String
+ let createdAt: String?
}
struct WaitlistCreateRequest: Codable {
@@ -37,7 +58,7 @@ struct WaitlistCreateRequest: Codable {
}
struct WaitlistResponse: Codable {
- let success: Bool
+ let success: Bool?
let message: String?
let data: WaitlistEntry?
}
@@ -47,29 +68,24 @@ struct WaitlistStats: Codable {
let today: Int?
let deviceBreakdown: [String: Int]?
let interestBreakdown: [String: Int]?
-
- enum CodingKeys: String, CodingKey {
- case total, today
- case deviceBreakdown = "deviceBreakdown"
- case interestBreakdown = "interestBreakdown"
- }
}
// MARK: - Auth
struct AuthResponse: Codable {
- let success: Bool
- let data: AuthTokens?
-}
-
-struct AuthTokens: Codable {
let accessToken: String
let refreshToken: String?
- let expiresIn: Int?
+ let user: AuthUser?
+}
- enum CodingKeys: String, CodingKey {
- case accessToken, refreshToken, expiresIn
- }
+struct AuthUser: Codable, Identifiable {
+ let id: String
+ let email: String?
+ let nickname: String?
+ let avatarUrl: String?
+ let role: String?
+ let status: String?
+ let onboardingCompleted: Bool?
}
struct AppleAuthRequest: Codable {
@@ -82,47 +98,70 @@ struct AppleAuthRequest: Codable {
}
}
-// MARK: - User
-
-struct UserProfileResponse: Codable {
- let success: Bool
- let data: UserProfileData?
+struct RefreshRequest: Codable {
+ let refreshToken: String
}
-struct UserProfileData: Codable, Identifiable {
+// MARK: - User Profile (matches GET /users/me with include: profile + preferences)
+
+struct UserProfileResponse: Codable, Identifiable {
let id: String
- let email: String
+ let email: String?
let nickname: String?
- let avatar: String?
- let preferences: UserPreferences?
- let stats: UserStats?
+ let avatarUrl: String?
+ let role: String?
+ let status: String?
+ let onboardingCompleted: Bool?
let createdAt: String?
+ let profile: UserProfileData?
+ let preferences: UserPreferences?
+}
+
+struct UserProfileData: Codable {
+ let learningIdentity: String?
+ let learningDirection: String?
+ let bio: String?
+ let currentGoal: String?
}
struct UserPreferences: Codable {
- let dailyGoal: Int?
- let reminderTime: String?
- let theme: String?
+ let preferredMethods: [String]?
+ let defaultFocusMinutes: Int?
+ let aiSuggestionLevel: String?
+ let language: String?
+ let appearance: String?
+ let notificationEnabled: Bool?
}
-struct UserStats: Codable {
- let totalLearningDays: Int?
- let completedCourses: Int?
- let totalMinutes: Int?
-}
-
-struct UpdateUserRequest: Codable {
+struct UpdateProfileRequest: Codable {
let nickname: String?
- let preferences: UserPreferences?
+ let avatarUrl: String?
}
-// MARK: - Knowledge Base
+struct UpdatePreferencesRequest: Codable {
+ let preferredMethods: [String]?
+ let defaultFocusMinutes: Int?
+ let aiSuggestionLevel: String?
+ let language: String?
+ let appearance: String?
+ let notificationEnabled: Bool?
+}
+
+struct UpdateProfileDataRequest: Codable {
+ let learningIdentity: String?
+ let learningDirection: String?
+ let bio: String?
+ let currentGoal: String?
+}
+
+// MARK: - Knowledge Base (matches Prisma KnowledgeBase model)
struct KnowledgeBase: Codable, Identifiable {
let id: String
let userId: String?
let title: String
let description: String?
+ let coverKey: String?
let status: String?
let itemCount: Int?
let lastStudiedAt: String?
@@ -130,184 +169,210 @@ struct KnowledgeBase: Codable, Identifiable {
let updatedAt: String?
}
-typealias KnowledgeBaseListResponse = [KnowledgeBase]
-
struct CreateKnowledgeBaseRequest: Codable {
- let name: String
+ let title: String
let description: String?
- let icon: String?
}
-// MARK: - Knowledge Items
+// MARK: - Knowledge Items (matches Prisma KnowledgeItem model)
struct KnowledgeItem: Codable, Identifiable {
let id: String
+ let userId: String?
+ let knowledgeBaseId: String?
+ let parentId: String?
+ let itemType: String?
let title: String
let content: String?
- let baseId: String?
- let tags: [String]?
- let mastery: Double?
+ let summary: String?
+ let sourceType: String?
+ let sourceRef: String?
+ let orderIndex: Int?
let status: String?
let createdAt: String?
-
- enum CodingKeys: String, CodingKey {
- case id, title, content, tags, mastery, status, createdAt
- case baseId = "baseId"
- }
-}
-
-struct KnowledgeItemListResponse: Codable {
- let success: Bool
- let data: [KnowledgeItem]?
+ let updatedAt: String?
}
struct CreateKnowledgeItemRequest: Codable {
+ let knowledgeBaseId: String
let title: String
let content: String?
- let baseId: String
- let tags: [String]?
-
- enum CodingKeys: String, CodingKey {
- case title, content, tags
- case baseId = "baseId"
- }
+ let itemType: String?
}
-// MARK: - AI Analysis
+struct UpdateKnowledgeItemRequest: Codable {
+ let title: String?
+ let content: String?
+ let summary: String?
+}
+
+// MARK: - Active Recall (matches ActiveRecallQuestion / Answer models)
+
+struct ActiveRecallQuestion: Codable, Identifiable {
+ let id: String
+ let userId: String?
+ let knowledgeItemId: String?
+ let questionText: String
+ let difficulty: String?
+ let createdBy: String?
+ let createdAt: String?
+}
+
+struct ActiveRecallAnswer: Codable, Identifiable {
+ let id: String
+ let userId: String?
+ let questionId: String?
+ let answerType: String?
+ let answerText: String?
+ let submittedAt: String?
+}
+
+struct SubmitAnswerRequest: Codable {
+ let answerText: String
+}
+
+// MARK: - AI Analysis (matches AiAnalysisResult model)
struct AIAnalysisRequest: Codable {
- let text: String
- let type: String
- let context: AIAnalysisContext?
-
- struct AIAnalysisContext: Codable {
- let knowledgeBaseIds: [String]?
- let focusItemIds: [String]?
- }
-}
-
-struct AIAnalysisResponse: Codable {
- let success: Bool
- let data: AIAnalysisResult?
+ let questionText: String?
+ let knowledgeItemContent: String?
+ let userAnswer: String?
+ let text: String?
+ let type: String?
}
struct AIAnalysisResult: Codable, Identifiable {
let id: String
- let type: String?
+ let userId: String?
let summary: String?
+ let masteryScore: Int?
let strengths: [String]?
let weaknesses: [String]?
let suggestions: [String]?
- let score: Double?
+ let nextActions: [String]?
+ let rawResult: AIAnalysisRawResult?
let createdAt: String?
}
+struct AIAnalysisRawResult: Codable {
+ let score: Double?
+ let analysis: String?
+ let focusItems: [String]?
+}
+
+// MARK: - Learning Session (matches Prisma LearningSession model)
+
+struct LearningSession: Codable, Identifiable {
+ let id: String
+ let userId: String?
+ let knowledgeBaseId: String?
+ let knowledgeItemId: String?
+ let mode: String?
+ let status: String?
+ let startedAt: String?
+ let endedAt: String?
+ let durationSeconds: Int?
+ let focusMinutes: Int?
+ let createdAt: String?
+}
+
+struct CreateLearningSessionRequest: Codable {
+ let knowledgeBaseId: String?
+ let knowledgeItemId: String?
+ let mode: String?
+}
+
+// MARK: - Review (matches ReviewCard / ReviewLog models)
+
+struct ReviewCard: Codable, Identifiable {
+ let id: String
+ let userId: String?
+ let knowledgeItemId: String?
+ let frontText: String
+ let backText: String?
+ let difficulty: String?
+ let status: String?
+ let nextReviewAt: String?
+ let intervalDays: Int?
+ let easeFactor: Double?
+ let repetitionCount: Int?
+ let lapseCount: Int?
+}
+
+struct SubmitReviewRequest: Codable {
+ let rating: String
+ let responseText: String?
+}
+
+// MARK: - Focus Items (matches Prisma FocusItem model)
+
+struct FocusItem: Codable, Identifiable {
+ let id: String
+ let userId: String?
+ let knowledgeBaseId: String?
+ let knowledgeItemId: String?
+ let title: String
+ let reason: String?
+ let suggestion: String?
+ let priority: String?
+ let status: String?
+ let masteryScore: Int?
+ let dueAt: String?
+ let completedAt: String?
+ let createdAt: String?
+}
+
+// MARK: - Activity (matches DailyLearningActivity + summary aggregation)
+
+struct ActivitySummary: Codable {
+ let totalMinutes: Int?
+ let totalCardsReviewed: Int?
+ let activeDays: Int?
+ let dailyAverage: Int?
+}
+
+struct ActivityHeatmap: Codable {
+ // Dictionary of "YYYY-MM-DD" -> durationSeconds
+}
+
// MARK: - Feedback
struct FeedbackCreateRequest: Codable {
- let type: String
+ let category: String
let content: String
- let contact: String?
+ let email: String?
- init(type: String = "general", content: String, contact: String? = nil) {
- self.type = type
+ init(category: String = "general", content: String, email: String? = nil) {
+ self.category = category
self.content = content
- self.contact = contact
+ self.email = email
}
}
-struct FeedbackResponse: Codable {
- let success: Bool
- let message: String?
- let data: FeedbackData?
-}
-
struct FeedbackData: Codable, Identifiable {
- let id: String
- let type: String?
+ let id: String?
+ let category: String?
let content: String?
let status: String?
let createdAt: String?
}
-// MARK: - Learning Session
+// MARK: - Notifications (matches Prisma Notification model)
-struct LearningSessionCreateRequest: Codable {
- let knowledgeBaseId: String?
- let notes: String?
-
- enum CodingKeys: String, CodingKey {
- case notes
- case knowledgeBaseId = "baseId"
- }
-}
-
-struct LearningSessionResponse: Codable {
- let success: Bool
- let data: LearningSessionData?
-}
-
-struct LearningSessionData: Codable, Identifiable {
+struct NotificationItem: Codable, Identifiable {
let id: String
- let startedAt: String?
- let endedAt: String?
- let durationMinutes: Double?
-}
-
-// MARK: - Activity
-
-struct ActivitySummary: Codable {
- let totalSessions: Int?
- let totalMinutes: Double?
- let streakDays: Int?
- let weeklyMinutes: Double?
-}
-
-struct ActivitySummaryResponse: Codable {
- let success: Bool
- let data: ActivitySummary?
-}
-
-// MARK: - Reviews
-
-struct ReviewTask: Codable, Identifiable {
- let id: String
- let itemId: String?
- let itemName: String?
- let dueDate: String?
- let type: String?
-}
-
-struct ReviewListResponse: Codable {
- let success: Bool
- let data: [ReviewTask]?
-}
-
-// MARK: - Focus Items / Weak Points
-
-struct FocusItem: Codable, Identifiable {
- let id: String
- let itemId: String?
- let itemName: String?
- let reason: String?
- let priority: String?
- let completed: Bool?
+ let userId: String?
+ let type: String
+ let title: String
+ let content: String?
+ let data: [String: String]?
+ let readAt: String?
let createdAt: String?
}
-struct FocusItemListResponse: Codable {
- let success: Bool
- let data: [FocusItem]?
-}
-
-// MARK: - Generic API Response
-
-struct APIStatusResponse: Codable {
- let status: String?
- let success: Bool?
-}
+// MARK: - Generic
struct GenericSuccessResponse: Codable {
- let success: Bool
+ let success: Bool?
let message: String?
}
diff --git a/AIStudyApp/AIStudyApp/Core/Network/APIClient.swift b/AIStudyApp/AIStudyApp/Core/Network/APIClient.swift
index 53c3bd4..cf58870 100644
--- a/AIStudyApp/AIStudyApp/Core/Network/APIClient.swift
+++ b/AIStudyApp/AIStudyApp/Core/Network/APIClient.swift
@@ -28,6 +28,16 @@ actor APIClient {
method: String = "GET",
body: Encodable? = nil,
queryItems: [URLQueryItem]? = nil
+ ) async throws -> T {
+ try await performRequest(path, method: method, body: body, queryItems: queryItems, isRetry: false)
+ }
+
+ private func performRequest(
+ _ path: String,
+ method: String,
+ body: Encodable?,
+ queryItems: [URLQueryItem]?,
+ isRetry: Bool
) async throws -> T {
var components = URLComponents(url: APIConfig.url(path), resolvingAgainstBaseURL: true)!
if let queryItems { components.queryItems = queryItems }
@@ -54,10 +64,18 @@ actor APIClient {
switch httpResponse.statusCode {
case 200, 201:
do {
- return try JSONDecoder().decode(T.self, from: data)
+ let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data)
+ return envelope.data
} catch {
throw APIError.decodingFailed(error.localizedDescription)
}
+ case 401 where !isRetry:
+ if let newToken = await refreshAccessToken() {
+ self.token = newToken
+ return try await performRequest(path, method: method, body: body, queryItems: queryItems, isRetry: true)
+ }
+ await notifyTokenExpired()
+ throw APIError.unauthorized
case 401:
throw APIError.unauthorized
case 400..<500:
@@ -67,6 +85,30 @@ actor APIClient {
throw APIError.requestFailed(httpResponse.statusCode)
}
}
+
+ private func refreshAccessToken() async -> String? {
+ guard let refreshToken = KeychainHelper.getRefreshToken() else { return nil }
+ do {
+ let body = RefreshRequest(refreshToken: refreshToken)
+ let resp: AuthResponse = try await performRequest(
+ "/auth/refresh", method: "POST", body: body, queryItems: nil, isRetry: true
+ )
+ KeychainHelper.save(
+ accessToken: resp.accessToken,
+ refreshToken: resp.refreshToken,
+ userId: resp.user?.id ?? ""
+ )
+ return resp.accessToken
+ } catch {
+ return nil
+ }
+ }
+
+ private func notifyTokenExpired() async {
+ await MainActor.run {
+ NotificationCenter.default.post(name: .tokenExpired, object: nil)
+ }
+ }
}
// MARK: - Helper for encoding arbitrary Encodable
diff --git a/AIStudyApp/AIStudyApp/Core/Security/KeychainHelper.swift b/AIStudyApp/AIStudyApp/Core/Security/KeychainHelper.swift
new file mode 100644
index 0000000..637c82e
--- /dev/null
+++ b/AIStudyApp/AIStudyApp/Core/Security/KeychainHelper.swift
@@ -0,0 +1,62 @@
+import Foundation
+import Security
+
+enum KeychainHelper {
+ private static let service = "cloud.longde.AIStudyApp"
+ private static let accessTokenKey = "accessToken"
+ private static let refreshTokenKey = "refreshToken"
+ private static let userIdKey = "userId"
+
+ static func save(accessToken: String, refreshToken: String?, userId: String) {
+ save(key: accessTokenKey, value: accessToken)
+ if let rt = refreshToken { save(key: refreshTokenKey, value: rt) }
+ save(key: userIdKey, value: userId)
+ }
+
+ static func getAccessToken() -> String? { get(key: accessTokenKey) }
+ static func getRefreshToken() -> String? { get(key: refreshTokenKey) }
+ static func getUserId() -> String? { get(key: userIdKey) }
+
+ static func clear() {
+ delete(key: accessTokenKey)
+ delete(key: refreshTokenKey)
+ delete(key: userIdKey)
+ }
+
+ // MARK: - Private
+
+ private static func save(key: String, value: String) {
+ let data = Data(value.utf8)
+ delete(key: key)
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: key,
+ kSecValueData as String: data,
+ ]
+ SecItemAdd(query as CFDictionary, nil)
+ }
+
+ private static func get(key: String) -> String? {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: key,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ ]
+ var item: CFTypeRef?
+ guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
+ let data = item as? Data else { return nil }
+ return String(data: data, encoding: .utf8)
+ }
+
+ private static func delete(key: String) {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: key,
+ ]
+ SecItemDelete(query as CFDictionary)
+ }
+}
diff --git a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift
index 07b498a..31b83c3 100644
--- a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift
+++ b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift
@@ -12,7 +12,7 @@ class WaitlistService {
private let client = APIClient.shared
func join(email: String, nickname: String?, devices: [String]?,
- interests: [String]?, painpoint: String?, willingBeta: Bool = true) async throws -> WaitlistResponse {
+ interests: [String]?, painpoint: String?, willingBeta: Bool = true) async throws -> WaitlistEntry {
let body = WaitlistCreateRequest(email: email, nickname: nickname, devices: devices,
interests: interests, painpoint: painpoint, willingBeta: willingBeta)
return try await client.request("/waitlist", method: "POST", body: body)
@@ -37,16 +37,7 @@ class AuthService {
? AppleAuthRequest.AppleFullName(givenName: givenName, familyName: familyName)
: nil
)
- let resp: AuthResponse = try await client.request("/auth/apple", method: "POST", body: body)
- if let token = resp.data?.accessToken {
- await client.setToken(token)
- }
- return resp
- }
-
- func logout() async throws {
- let _: GenericSuccessResponse = try await client.request("/auth/logout", method: "POST")
- await client.setToken(nil)
+ return try await client.request("/auth/apple", method: "POST", body: body)
}
}
@@ -61,9 +52,20 @@ class UserService {
return try await client.request("/users/me")
}
- func updateProfile(nickname: String?, preferences: UserPreferences?) async throws -> UserProfileResponse {
- let body = UpdateUserRequest(nickname: nickname, preferences: preferences)
- return try await client.request("/users/me", method: "PATCH", body: body)
+ func updateProfile(_ dto: UpdateProfileRequest) async throws -> UserProfileResponse {
+ return try await client.request("/users/me", method: "PATCH", body: dto)
+ }
+
+ func updatePreferences(_ dto: UpdatePreferencesRequest) async throws -> UserPreferences {
+ return try await client.request("/users/me/preferences", method: "PATCH", body: dto)
+ }
+
+ func getProfileDetail() async throws -> UserProfileData {
+ return try await client.request("/users/me/profile")
+ }
+
+ func updateProfileDetail(_ dto: UpdateProfileDataRequest) async throws -> UserProfileData {
+ return try await client.request("/users/me/profile", method: "PATCH", body: dto)
}
}
@@ -74,18 +76,30 @@ class KnowledgeBaseService {
static let shared = KnowledgeBaseService()
private let client = APIClient.shared
- func list() async throws -> KnowledgeBaseListResponse {
- return try await client.request("/knowledge-bases")
+ func list(page: Int = 1, limit: Int = 20) async throws -> [KnowledgeBase] {
+ return try await client.request("/knowledge-bases", queryItems: [
+ URLQueryItem(name: "page", value: String(page)),
+ URLQueryItem(name: "limit", value: String(limit)),
+ ])
}
- func create(name: String, description: String?, icon: String?) async throws -> KnowledgeBaseListResponse {
- let body = CreateKnowledgeBaseRequest(name: name, description: description, icon: icon)
+ func create(title: String, description: String?) async throws -> KnowledgeBase {
+ let body = CreateKnowledgeBaseRequest(title: title, description: description)
return try await client.request("/knowledge-bases", method: "POST", body: body)
}
- func detail(id: String) async throws -> KnowledgeBaseListResponse {
+ func detail(id: String) async throws -> KnowledgeBase {
return try await client.request("/knowledge-bases/\(id)")
}
+
+ func update(id: String, title: String?, description: String?) async throws -> KnowledgeBase {
+ let body = CreateKnowledgeBaseRequest(title: title ?? "", description: description)
+ return try await client.request("/knowledge-bases/\(id)", method: "PATCH", body: body)
+ }
+
+ func delete(id: String) async throws -> GenericSuccessResponse {
+ return try await client.request("/knowledge-bases/\(id)", method: "DELETE")
+ }
}
// MARK: - Knowledge Items
@@ -95,18 +109,50 @@ class KnowledgeItemService {
static let shared = KnowledgeItemService()
private let client = APIClient.shared
- func list(baseId: String) async throws -> KnowledgeItemListResponse {
- return try await client.request("/knowledge-items", queryItems: [URLQueryItem(name: "baseId", value: baseId)])
+ func list(knowledgeBaseId: String) async throws -> [KnowledgeItem] {
+ return try await client.request("/knowledge-items", queryItems: [
+ URLQueryItem(name: "knowledgeBaseId", value: knowledgeBaseId),
+ ])
}
- func detail(id: String) async throws -> KnowledgeItemListResponse {
+ func detail(id: String) async throws -> KnowledgeItem {
return try await client.request("/knowledge-items/\(id)")
}
- func create(baseId: String, title: String, content: String?, tags: [String]?) async throws -> KnowledgeItemListResponse {
- let body = CreateKnowledgeItemRequest(title: title, content: content, baseId: baseId, tags: tags)
+ func create(knowledgeBaseId: String, title: String, content: String?, itemType: String? = nil) async throws -> KnowledgeItem {
+ let body = CreateKnowledgeItemRequest(
+ knowledgeBaseId: knowledgeBaseId,
+ title: title,
+ content: content,
+ itemType: itemType
+ )
return try await client.request("/knowledge-items", method: "POST", body: body)
}
+
+ func update(id: String, title: String?, content: String?, summary: String?) async throws -> KnowledgeItem {
+ let body = UpdateKnowledgeItemRequest(title: title, content: content, summary: summary)
+ return try await client.request("/knowledge-items/\(id)", method: "PATCH", body: body)
+ }
+}
+
+// MARK: - Active Recall
+
+@MainActor
+class ActiveRecallService {
+ static let shared = ActiveRecallService()
+ private let client = APIClient.shared
+
+ func questions(page: Int = 1, limit: Int = 20) async throws -> [ActiveRecallQuestion] {
+ return try await client.request("/active-recalls", queryItems: [
+ URLQueryItem(name: "page", value: String(page)),
+ URLQueryItem(name: "limit", value: String(limit)),
+ ])
+ }
+
+ func submit(questionId: String, answerText: String) async throws -> ActiveRecallAnswer {
+ let body = SubmitAnswerRequest(answerText: answerText)
+ return try await client.request("/active-recalls/\(questionId)/submit", method: "POST", body: body)
+ }
}
// MARK: - AI Analysis
@@ -116,12 +162,78 @@ class AIAnalysisService {
static let shared = AIAnalysisService()
private let client = APIClient.shared
- func analyze(text: String, type: String = "weakness", context: AIAnalysisRequest.AIAnalysisContext? = nil) async throws -> AIAnalysisResponse {
- let body = AIAnalysisRequest(text: text, type: type, context: context)
+ func analyze(questionText: String, knowledgeItemContent: String, userAnswer: String) async throws -> AIAnalysisResult {
+ let body = AIAnalysisRequest(
+ questionText: questionText,
+ knowledgeItemContent: knowledgeItemContent,
+ userAnswer: userAnswer,
+ text: nil,
+ type: nil
+ )
return try await client.request("/ai-analysis", method: "POST", body: body)
}
}
+// MARK: - Learning Sessions
+
+@MainActor
+class LearningSessionService {
+ static let shared = LearningSessionService()
+ private let client = APIClient.shared
+
+ func list(page: Int = 1, limit: Int = 20) async throws -> [LearningSession] {
+ return try await client.request("/learning-sessions", queryItems: [
+ URLQueryItem(name: "page", value: String(page)),
+ URLQueryItem(name: "limit", value: String(limit)),
+ ])
+ }
+
+ func start(knowledgeBaseId: String? = nil, knowledgeItemId: String? = nil, mode: String? = nil) async throws -> LearningSession {
+ let body = CreateLearningSessionRequest(
+ knowledgeBaseId: knowledgeBaseId,
+ knowledgeItemId: knowledgeItemId,
+ mode: mode
+ )
+ return try await client.request("/learning-sessions", method: "POST", body: body)
+ }
+
+ func end(id: String) async throws -> LearningSession {
+ return try await client.request("/learning-sessions/\(id)/end", method: "POST")
+ }
+}
+
+// MARK: - Review
+
+@MainActor
+class ReviewService {
+ static let shared = ReviewService()
+ private let client = APIClient.shared
+
+ func dueCards() async throws -> [ReviewCard] {
+ return try await client.request("/reviews/due")
+ }
+
+ func submit(id: String, rating: String, responseText: String? = nil) async throws -> GenericSuccessResponse {
+ let body = SubmitReviewRequest(rating: rating, responseText: responseText)
+ return try await client.request("/reviews/\(id)/submit", method: "POST", body: body)
+ }
+}
+
+// MARK: - Focus Items
+
+@MainActor
+class FocusItemService {
+ static let shared = FocusItemService()
+ private let client = APIClient.shared
+
+ func list(page: Int = 1, limit: Int = 20) async throws -> [FocusItem] {
+ return try await client.request("/focus-items", queryItems: [
+ URLQueryItem(name: "page", value: String(page)),
+ URLQueryItem(name: "limit", value: String(limit)),
+ ])
+ }
+}
+
// MARK: - Activity & Stats
@MainActor
@@ -129,32 +241,12 @@ class ActivityService {
static let shared = ActivityService()
private let client = APIClient.shared
- func summary() async throws -> ActivitySummaryResponse {
+ func summary() async throws -> ActivitySummary {
return try await client.request("/activity/summary")
}
-}
-// MARK: - Reviews
-
-@MainActor
-class ReviewService {
- static let shared = ReviewService()
- private let client = APIClient.shared
-
- func due() async throws -> ReviewListResponse {
- return try await client.request("/reviews/due")
- }
-}
-
-// MARK: - Focus Items / Weak Points
-
-@MainActor
-class FocusItemService {
- static let shared = FocusItemService()
- private let client = APIClient.shared
-
- func list() async throws -> FocusItemListResponse {
- return try await client.request("/focus-items")
+ func heatmap() async throws -> [String: Int] {
+ return try await client.request("/activity/heatmap")
}
}
@@ -165,8 +257,27 @@ class FeedbackService {
static let shared = FeedbackService()
private let client = APIClient.shared
- func submit(type: String = "general", content: String, contact: String? = nil) async throws -> FeedbackResponse {
- let body = FeedbackCreateRequest(type: type, content: content, contact: contact)
+ func submit(category: String = "general", content: String, email: String? = nil) async throws -> FeedbackData {
+ let body = FeedbackCreateRequest(category: category, content: content, email: email)
return try await client.request("/feedback", method: "POST", body: body)
}
}
+
+// MARK: - Notifications
+
+@MainActor
+class NotificationService {
+ static let shared = NotificationService()
+ private let client = APIClient.shared
+
+ func list(page: Int = 1, limit: Int = 20) async throws -> [NotificationItem] {
+ return try await client.request("/notifications", queryItems: [
+ URLQueryItem(name: "page", value: String(page)),
+ URLQueryItem(name: "limit", value: String(limit)),
+ ])
+ }
+
+ func markRead(id: String) async throws -> NotificationItem {
+ return try await client.request("/notifications/\(id)/read", method: "PATCH")
+ }
+}
diff --git a/AIStudyApp/AIStudyApp/Features/AI/AIAnalysisViewModel.swift b/AIStudyApp/AIStudyApp/Features/AI/AIAnalysisViewModel.swift
new file mode 100644
index 0000000..9969dad
--- /dev/null
+++ b/AIStudyApp/AIStudyApp/Features/AI/AIAnalysisViewModel.swift
@@ -0,0 +1,24 @@
+import Combine
+import Foundation
+
+@MainActor
+class AIAnalysisViewModel: ObservableObject {
+ @Published var analysisResult: AIAnalysisResult?
+ @Published var isAnalyzing = false
+ @Published var errorMessage: String?
+
+ func requestAnalysis(questionText: String, knowledgeItemContent: String, userAnswer: String) async {
+ isAnalyzing = true
+ errorMessage = nil
+ do {
+ analysisResult = try await AIAnalysisService.shared.analyze(
+ questionText: questionText,
+ knowledgeItemContent: knowledgeItemContent,
+ userAnswer: userAnswer
+ )
+ } catch {
+ errorMessage = "AI 分析失败"
+ }
+ isAnalyzing = false
+ }
+}
diff --git a/AIStudyApp/AIStudyApp/Features/AI/AIHomeView.swift b/AIStudyApp/AIStudyApp/Features/AI/AIHomeView.swift
index 3240bcc..d1aa7ed 100644
--- a/AIStudyApp/AIStudyApp/Features/AI/AIHomeView.swift
+++ b/AIStudyApp/AIStudyApp/Features/AI/AIHomeView.swift
@@ -7,7 +7,7 @@ import SwiftUI
struct AIHomeView: View {
@State private var text = ""
@State private var serverStatus: ServerStatus = .checking
- @State private var knowledgeCount = 0
+ @State private var serverMessage = ""
@State private var navigateToChat = false
enum ServerStatus { case checking, online, offline }
@@ -32,7 +32,7 @@ struct AIHomeView: View {
: serverStatus == .checking ? Color.zxYellow
: Color.zxRed)
.frame(width: 6, height: 6)
- Text(serverStatus == .online ? "API \(knowledgeCount)"
+ Text(serverStatus == .online ? serverMessage
: serverStatus == .checking ? "检测中…"
: "离线")
.font(.system(size: 10, weight: .medium))
@@ -69,13 +69,13 @@ struct AIHomeView: View {
private func checkServer() async {
serverStatus = .checking
do {
- let resp: KnowledgeBaseListResponse = try await APIClient.shared.request("/knowledge-bases")
- let count = resp.count
- knowledgeCount = count
+ struct HealthResponse: Decodable { let status: String }
+ let resp: HealthResponse = try await APIClient.shared.request("/")
serverStatus = .online
+ serverMessage = resp.status
} catch {
serverStatus = .offline
- print("[API] 服务器检测失败: \(error.localizedDescription)")
+ serverMessage = ""
}
}
diff --git a/AIStudyApp/AIStudyApp/Features/AI/ActiveRecallView.swift b/AIStudyApp/AIStudyApp/Features/AI/ActiveRecallView.swift
index 6058889..7fcebb9 100644
--- a/AIStudyApp/AIStudyApp/Features/AI/ActiveRecallView.swift
+++ b/AIStudyApp/AIStudyApp/Features/AI/ActiveRecallView.swift
@@ -1,6 +1,7 @@
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),
@@ -38,6 +39,7 @@ struct ActiveRecallView: View {
}
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
+ .task { await viewModel.loadQuestions() }
}
private var isSubmitted: Bool { submitted.contains(current.id) }
diff --git a/AIStudyApp/AIStudyApp/Features/AI/ActiveRecallViewModel.swift b/AIStudyApp/AIStudyApp/Features/AI/ActiveRecallViewModel.swift
new file mode 100644
index 0000000..e16efd4
--- /dev/null
+++ b/AIStudyApp/AIStudyApp/Features/AI/ActiveRecallViewModel.swift
@@ -0,0 +1,51 @@
+import Combine
+import Foundation
+
+@MainActor
+class ActiveRecallViewModel: ObservableObject {
+ @Published var questions: [ActiveRecallQuestion] = []
+ @Published var currentIndex = 0
+ @Published var isSubmitting = false
+ @Published var errorMessage: String?
+ @Published var lastResult: ActiveRecallAnswer?
+
+ var currentQuestion: ActiveRecallQuestion? {
+ guard currentIndex < questions.count else { return nil }
+ return questions[currentIndex]
+ }
+
+ func loadQuestions() async {
+ isSubmitting = false
+ errorMessage = nil
+ do {
+ questions = try await ActiveRecallService.shared.questions()
+ currentIndex = 0
+ } catch {
+ errorMessage = "加载问题失败"
+ }
+ }
+
+ func submitAnswer(text: String) async {
+ guard let question = currentQuestion else { return }
+ isSubmitting = true
+ errorMessage = nil
+ do {
+ lastResult = try await ActiveRecallService.shared.submit(
+ questionId: question.id, answerText: text
+ )
+ currentIndex += 1
+ } catch {
+ errorMessage = "提交回答失败"
+ }
+ isSubmitting = false
+ }
+
+ var isComplete: Bool {
+ currentIndex >= questions.count
+ }
+
+ var progress: Double {
+ guard !questions.isEmpty else { return 0 }
+ return Double(currentIndex) / Double(questions.count)
+ }
+}
diff --git a/AIStudyApp/AIStudyApp/Features/AI/DailyThinkingPage.swift b/AIStudyApp/AIStudyApp/Features/AI/DailyThinkingPage.swift
index 5ccad7e..8976eb2 100644
--- a/AIStudyApp/AIStudyApp/Features/AI/DailyThinkingPage.swift
+++ b/AIStudyApp/AIStudyApp/Features/AI/DailyThinkingPage.swift
@@ -17,8 +17,8 @@ struct DailyThinkingPage: View {
}
struct RecallTestPage: View { @State private var input = ""; var body: some View { ZStack{Color.zxBg0.ignoresSafeArea();ScrollView{VStack(spacing:16){Text("请回忆并写下你对「偏差-方差权衡」的理解").font(.system(size:14)).foregroundColor(Color.zxF04);TextEditor(text:$input).frame(minHeight:200).scrollContentBackground(.hidden).padding(12).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius:14)).overlay(RoundedRectangle(cornerRadius:14).stroke(Color.zxBorder008,lineWidth:1));NavigationLink(destination: AIFeedbackPageView()){Text("提交").font(.system(size:14,weight:.bold)).foregroundColor(.white).frame(maxWidth:.infinity).frame(height:52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius:16))}}.padding(.horizontal,20).padding(.top, 8).padding(.bottom,80)}.scrollIndicators(.hidden)}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden,for:.navigationBar)} }
struct WeakPointsPage: View { var body: some View { ZStack{Color.zxBg0.ignoresSafeArea();ScrollView{VStack(spacing:12){
- NavigationLink(destination: KnowledgeDetailPage()) { ZXWeakRow(score:32,topic:"贝叶斯定理应用",lib:"机器学习",priority:"高") }.foregroundColor(.primary)
- NavigationLink(destination: KnowledgeDetailPage()) { ZXWeakRow(score:41,topic:"正态分布性质",lib:"高等数学",priority:"高") }.foregroundColor(.primary)
+ ZXWeakRow(score:32,topic:"贝叶斯定理应用",lib:"机器学习",priority:"高")
+ ZXWeakRow(score:41,topic:"正态分布性质",lib:"高等数学",priority:"高")
ZXWeakRow(score:55,topic:"词根 spect- 相关词汇",lib:"英语词汇",priority:"中")
ZXWeakRow(score:48,topic:"协方差与相关系数",lib:"机器学习",priority:"中")
ZXWeakRow(score:36,topic:"梯度下降优化",lib:"机器学习",priority:"高")
diff --git a/AIStudyApp/AIStudyApp/Features/Analysis/ActivityViewModel.swift b/AIStudyApp/AIStudyApp/Features/Analysis/ActivityViewModel.swift
new file mode 100644
index 0000000..e0b48e6
--- /dev/null
+++ b/AIStudyApp/AIStudyApp/Features/Analysis/ActivityViewModel.swift
@@ -0,0 +1,48 @@
+import Combine
+import Foundation
+
+@MainActor
+class ActivityViewModel: ObservableObject {
+ @Published var summary: ActivitySummary?
+ @Published var focusItems: [FocusItem] = []
+ @Published var heatmap: [String: Int] = [:]
+ @Published var isLoading = false
+ @Published var errorMessage: String?
+
+ func loadSummary() async {
+ isLoading = true
+ errorMessage = nil
+ do {
+ summary = try await ActivityService.shared.summary()
+ } catch {
+ errorMessage = "加载学习统计失败"
+ }
+ isLoading = false
+ }
+
+ func loadFocusItems() async {
+ do {
+ focusItems = try await FocusItemService.shared.list()
+ } catch {
+ errorMessage = "加载弱项列表失败"
+ }
+ }
+
+ func loadHeatmap() async {
+ do {
+ heatmap = try await ActivityService.shared.heatmap()
+ } catch {
+ // heatmap is non-critical, silently fail
+ }
+ }
+
+ func loadAll() async {
+ isLoading = true
+ errorMessage = nil
+ async let summaryTask: () = loadSummary()
+ async let focusTask: () = loadFocusItems()
+ async let heatmapTask: () = loadHeatmap()
+ _ = await (summaryTask, focusTask, heatmapTask)
+ isLoading = false
+ }
+}
diff --git a/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift b/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift
index a0ff28f..24cab44 100644
--- a/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift
+++ b/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift
@@ -1,6 +1,7 @@
import SwiftUI
struct AnalysisHomeView: View {
+ @StateObject private var viewModel = ActivityViewModel()
var body: some View {
ZStack {
Color.zxBg0.ignoresSafeArea()
@@ -15,25 +16,29 @@ struct AnalysisHomeView: View {
ScrollView {
VStack(spacing: 16) {
HStack(spacing: 12) {
- ZXStatBadge(icon: "trophy.fill", label: "综合掌握", value: "65%", trend: "+8%", color: Color.zxPurple)
- ZXStatBadge(icon: "bolt.fill", label: "本周积分", value: "1,240", trend: "+320", color: Color.zxOrange)
- ZXStatBadge(icon: "exclamationmark.triangle.fill", label: "待巩固", value: "23", trend: "-5", color: Color.zxYellow)
- ZXStatBadge(icon: "chart.line.uptrend.xyaxis", label: "连续天", value: "14", trend: "🔥", color: Color.zxGreen)
+ ZXStatBadge(icon: "trophy.fill", label: "综合掌握", value: "\(viewModel.summary?.dailyAverage ?? 0)%", trend: "", color: Color.zxPurple)
+ ZXStatBadge(icon: "bolt.fill", label: "总分钟", value: "\(viewModel.summary?.totalMinutes ?? 0)", trend: "", color: Color.zxOrange)
+ ZXStatBadge(icon: "exclamationmark.triangle.fill", label: "复习卡片", value: "\(viewModel.summary?.totalCardsReviewed ?? 0)", trend: "", color: Color.zxYellow)
+ ZXStatBadge(icon: "chart.line.uptrend.xyaxis", label: "活跃天", value: "\(viewModel.summary?.activeDays ?? 0)", trend: "", color: Color.zxGreen)
}
VStack(alignment: .leading, spacing: 16) {
HStack { Text("掌握度趋势").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0); Spacer(); Text("↑ +8% 本周").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxGreen) }
ZXChartView()
}.padding(16).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20))
VStack(alignment: .leading, spacing: 12) {
- HStack { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 14)).foregroundColor(Color.zxYellow); Text("薄弱知识点").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0) }; Spacer(); NavigationLink(destination: WeakPointsPage()) { Text("全部 23 个").font(.system(size: 12)).foregroundColor(Color.zxPurple) } }
- NavigationLink(destination: KnowledgeDetailPage()) { ZXWeakRow(score: 32, topic: "贝叶斯定理应用", lib: "机器学习", priority: "高") }.foregroundColor(.primary)
- NavigationLink(destination: KnowledgeDetailPage()) { ZXWeakRow(score: 41, topic: "正态分布性质", lib: "高等数学", priority: "高") }.foregroundColor(.primary)
- ZXWeakRow(score: 55, topic: "词根 spect- 相关词汇", lib: "英语词汇", priority: "中")
+ HStack { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 14)).foregroundColor(Color.zxYellow); Text("薄弱知识点").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0) }; Spacer(); NavigationLink(destination: WeakPointsPage()) { Text("全部 \(viewModel.focusItems.count) 个").font(.system(size: 12)).foregroundColor(Color.zxPurple) } }
+ ForEach(viewModel.focusItems.prefix(5)) { item in
+ ZXWeakRow(score: item.masteryScore ?? 0, topic: item.title, lib: item.knowledgeBaseId ?? "", priority: item.priority ?? "normal")
+ }
+ if viewModel.focusItems.isEmpty && !viewModel.isLoading {
+ Text("暂无薄弱知识点").font(.system(size: 13)).foregroundColor(Color.zxF03)
+ }
}
}.padding(.horizontal, 20).padding(.bottom, 120)
}.scrollIndicators(.hidden)
}
}
+ .task { await viewModel.loadAll() }
}
}
diff --git a/AIStudyApp/AIStudyApp/Features/Library/LibraryHomeView.swift b/AIStudyApp/AIStudyApp/Features/Library/LibraryHomeView.swift
index b0d4ec0..edff226 100644
--- a/AIStudyApp/AIStudyApp/Features/Library/LibraryHomeView.swift
+++ b/AIStudyApp/AIStudyApp/Features/Library/LibraryHomeView.swift
@@ -5,6 +5,7 @@
import SwiftUI
struct LibraryHomeView: View {
+ @StateObject private var viewModel = LibraryViewModel()
@State private var s = ""
var body: some View {
ZStack { ZXGradient.page.ignoresSafeArea()
@@ -26,10 +27,14 @@ struct LibraryHomeView: View {
HStack(spacing: 8) { Image(systemName: "magnifyingglass").font(.system(size: 16)).foregroundColor(Color.zxF03); TextField("搜索知识库或知识点…", text: $s).font(.system(size: 14)).tint(Color.zxPurple) }
.padding(.horizontal, 14).frame(height: 44).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 14)).padding(.horizontal, 20).padding(.bottom, 16)
ScrollView { VStack(spacing: 12) {
- NavigationLink(destination: LibraryDetailPage()) { ZLibraryCard(emoji: "🤖", name: "机器学习", desc: "ML基础 · 深度学习 · 实战项目", color: Color.zxPurple, items: 47, mastery: 72, tags: ["算法","数学","实战"], last: "今天") }
- NavigationLink(destination: LibraryDetailPage()) { ZLibraryCard(emoji: "📐", name: "高等数学", desc: "微积分 · 线代 · 概率论", color: Color.zxOrange, items: 93, mastery: 58, tags: ["公式","定理","习题"], last: "昨天") }
- NavigationLink(destination: LibraryDetailPage()) { ZLibraryCard(emoji: "📖", name: "英语词汇", desc: "GRE · 托福 · 商务英语", color: Color.zxTeal, items: 312, mastery: 84, tags: ["词根","语境","拼写"], last: "3天前") }
- NavigationLink(destination: LibraryDetailPage()) { ZLibraryCard(emoji: "🎨", name: "产品设计", desc: "UX 方法论 · 用研 · 交互规范", color: Color.zxYellow, items: 28, mastery: 43, tags: ["方法论","案例"], last: "1周前") }
+ ForEach(viewModel.knowledgeBases) { kb in
+ NavigationLink(destination: LibraryDetailPage(knowledgeBaseId: kb.id)) {
+ ZLibraryCard(emoji: "📚", name: kb.title, desc: kb.description ?? "", color: Color.zxPurple, items: kb.itemCount ?? 0, mastery: 50, tags: [], last: lastStudiedText(kb.lastStudiedAt))
+ }
+ }
+ if viewModel.knowledgeBases.isEmpty && !viewModel.isLoading {
+ Text("还没有知识库,点击右上角 + 创建").font(.system(size: 13)).foregroundColor(Color.zxF03).padding(.top, 40)
+ }
NavigationLink(destination: CreateLibraryPage()) {
HStack(spacing: 8) { Image(systemName: "plus").font(.system(size: 16)); Text("创建新知识库").font(.system(size: 14, weight: .semibold)) }
.foregroundColor(Color.zxF05).frame(maxWidth: .infinity).frame(height: 52).background(Color.zxFill003)
@@ -39,6 +44,12 @@ struct LibraryHomeView: View {
}.padding(.horizontal, 20).padding(.bottom, 120) }.scrollIndicators(.hidden)
}
}
+ .task { await viewModel.loadKnowledgeBases() }
+ }
+
+ private func lastStudiedText(_ iso: String?) -> String {
+ guard let iso else { return "未学习" }
+ return iso.prefix(10).description
}
}
struct ZLibraryCard: View { let emoji: String; let name: String; let desc: String; let color: Color; let items: Int; let mastery: Int; let tags: [String]; let last: String
diff --git a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift
index 24ad5a2..f2566cd 100644
--- a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift
+++ b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift
@@ -7,27 +7,37 @@ struct CreateLibraryPage: View {
ScrollView { VStack(spacing: 20) {
VStack(alignment: .leading, spacing: 8) { Text("知识库名称").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextField("例如:机器学习", text: $name).font(.system(size: 15)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 52).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) }
VStack(alignment: .leading, spacing: 8) { Text("描述(可选)").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextField("简单描述这个知识库的内容", text: $desc).font(.system(size: 15)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 52).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) }
- Button { } label: { Text("创建").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) }
+ Button {
+ Task { _ = try? await KnowledgeBaseService.shared.create(title: name, description: desc.isEmpty ? nil : desc) }
+ } label: { Text("创建").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) }
}.padding(.horizontal, 20).padding(.top, 20) }.scrollIndicators(.hidden) }
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)}
}
struct LibraryDetailPage: View {
+ let knowledgeBaseId: String
+ @StateObject private var viewModel = LibraryDetailViewModel()
var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
HStack { Spacer()
- NavigationLink(destination: AddKnowledgePage()) {
+ NavigationLink(destination: AddKnowledgePage(knowledgeBaseId: knowledgeBaseId)) {
Image(systemName: "plus").font(.system(size: 18)).foregroundColor(.white)
.frame(width: 36, height: 36).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: 10))
}
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 8)
ScrollView { VStack(spacing: 12) {
- NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "偏差-方差权衡", desc: "模型复杂度 · 泛化误差", status: "已掌握", c: Color.zxGreen) }
- NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "梯度下降优化", desc: "SGD · Adam · 学习率", status: "学习中", c: Color.zxOrange) }
- NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "正则化方法", desc: "L1 · L2 · Dropout", status: "待复习", c: Color.zxYellow) }
- NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "过拟合与欠拟合", desc: "偏差方差 · 模型选择", status: "已掌握", c: Color.zxGreen) }
+ ForEach(viewModel.items) { item in
+ NavigationLink(destination: KnowledgeDetailPage(item: item)) {
+ ZXCardRow(emoji: "📝", title: item.title, desc: item.summary ?? item.content ?? "", status: item.status ?? "active", c: Color.zxGreen)
+ }
+ }
+ if viewModel.items.isEmpty && !viewModel.isLoading {
+ Text("暂无知识点").font(.system(size: 13)).foregroundColor(Color.zxF03).padding(.top, 40)
+ }
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden) }
- }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)}
+ }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)
+ .task { await viewModel.loadItems(knowledgeBaseId: knowledgeBaseId) }
+ }
}
struct ZXCardRow: View { let emoji: String; let title: String; let desc: String; let status: String; let c: Color
var body: some View { HStack(spacing: 12) { Text(emoji).font(.system(size: 20)).frame(width: 40, height: 40).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 10)); VStack(alignment: .leading, spacing: 2) { Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 11)).foregroundColor(Color.zxF03) }; Spacer(); Text(status).font(.system(size: 10, weight: .semibold)).foregroundColor(c).padding(.horizontal, 8).padding(.vertical, 2).background(c.opacity(0.12)).clipShape(Capsule()) }
@@ -35,22 +45,26 @@ struct ZXCardRow: View { let emoji: String; let title: String; let desc: String;
}
struct AddKnowledgePage: View {
+ let knowledgeBaseId: String
@State private var title = ""; @State private var content = ""
var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
ScrollView { VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 8) { Text("标题").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextField("输入知识点标题", text: $title).font(.system(size: 15)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 52).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) }
VStack(alignment: .leading, spacing: 8) { Text("内容").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextEditor(text: $content).frame(minHeight: 200).scrollContentBackground(.hidden).padding(12).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) }
- Button { } label: { Text("保存").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) }
+ Button {
+ Task { _ = try? await KnowledgeItemService.shared.create(knowledgeBaseId: knowledgeBaseId, title: title, content: content.isEmpty ? nil : content) }
+ } label: { Text("保存").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) }
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80) }.scrollIndicators(.hidden) }
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)}
}
struct KnowledgeDetailPage: View {
+ let item: KnowledgeItem
var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
HStack { Spacer()
- NavigationLink(destination: EditKnowledgePage()) {
+ NavigationLink(destination: EditKnowledgePage(item: item)) {
Image(systemName: "pencil").font(.system(size: 16)).foregroundColor(Color.zxF05)
.frame(width: 36, height: 36).background(Color(hex:"#FFFFFF",opacity:0.05))
.clipShape(RoundedRectangle(cornerRadius: 10))
@@ -58,7 +72,14 @@ struct KnowledgeDetailPage: View {
}
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 8)
ScrollView { VStack(spacing: 16) {
- VStack(alignment: .leading, spacing: 8) { HStack { ZXChip(text: "算法", color: Color.zxPurple); ZXChip(text: "机器学习", color: Color.zxAccent); ZXChip(text: "需要复习", color: Color.zxYellow) }; Text("偏差-方差权衡").font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxF0); Text("偏差-方差权衡是机器学习模型选择的核心理念。").font(.system(size: 14)).foregroundColor(Color.zxF007).lineSpacing(6) }.padding(20).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20)).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ if let itemType = item.itemType { ZXChip(text: itemType, color: Color.zxPurple) }
+ if let sourceType = item.sourceType { ZXChip(text: sourceType, color: Color.zxAccent) }
+ }
+ Text(item.title).font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxF0)
+ if let content = item.content { Text(content).font(.system(size: 14)).foregroundColor(Color.zxF007).lineSpacing(6) }
+ }.padding(20).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20)).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
HStack(spacing: 12) {
NavigationLink(destination: StudyHomeView()) {
Label("开始复习", systemImage: "arrow.triangle.2.circlepath").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 44).background(ZXGradient.brandPurple).clipShape(RoundedRectangle(cornerRadius: 14))
@@ -90,13 +111,22 @@ struct ZXImportOption: View { let icon: String; let title: String; let desc: Str
}
struct EditKnowledgePage: View {
- @State private var title = "偏差-方差权衡"; @State private var content = "偏差衡量模型的预测与真实值之间的差异..."
+ let item: KnowledgeItem
+ @State private var title: String; @State private var content: String
+
+ init(item: KnowledgeItem) {
+ self.item = item
+ _title = State(initialValue: item.title)
+ _content = State(initialValue: item.content ?? "")
+ }
var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
ScrollView { VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 8) { Text("标题").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextField("", text: $title).font(.system(size: 15)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 52).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) }
VStack(alignment: .leading, spacing: 8) { Text("内容").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextEditor(text: $content).frame(minHeight: 200).scrollContentBackground(.hidden).padding(12).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) }
- Button { } label: { Text("保存修改").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) }
+ Button {
+ Task { _ = try? await KnowledgeItemService.shared.update(id: item.id, title: title, content: content, summary: nil) }
+ } label: { Text("保存修改").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) }
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80) }.scrollIndicators(.hidden) }
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)}
}
diff --git a/AIStudyApp/AIStudyApp/Features/Library/LibraryViewModel.swift b/AIStudyApp/AIStudyApp/Features/Library/LibraryViewModel.swift
new file mode 100644
index 0000000..b8dee1e
--- /dev/null
+++ b/AIStudyApp/AIStudyApp/Features/Library/LibraryViewModel.swift
@@ -0,0 +1,80 @@
+import Combine
+import Foundation
+
+@MainActor
+class LibraryViewModel: ObservableObject {
+ @Published var knowledgeBases: [KnowledgeBase] = []
+ @Published var isLoading = false
+ @Published var errorMessage: String?
+
+ func loadKnowledgeBases() async {
+ isLoading = true
+ errorMessage = nil
+ do {
+ knowledgeBases = try await KnowledgeBaseService.shared.list()
+ } catch {
+ errorMessage = "加载知识库失败"
+ }
+ isLoading = false
+ }
+
+ func createKnowledgeBase(title: String, description: String?) async -> KnowledgeBase? {
+ do {
+ let kb = try await KnowledgeBaseService.shared.create(title: title, description: description)
+ knowledgeBases.insert(kb, at: 0)
+ return kb
+ } catch {
+ errorMessage = "创建知识库失败"
+ return nil
+ }
+ }
+
+ func deleteKnowledgeBase(id: String) async {
+ do {
+ _ = try await KnowledgeBaseService.shared.delete(id: id)
+ knowledgeBases.removeAll { $0.id == id }
+ } catch {
+ errorMessage = "删除知识库失败"
+ }
+ }
+}
+
+@MainActor
+class LibraryDetailViewModel: ObservableObject {
+ @Published var items: [KnowledgeItem] = []
+ @Published var knowledgeBase: KnowledgeBase?
+ @Published var isLoading = false
+ @Published var errorMessage: String?
+
+ func loadItems(knowledgeBaseId: String) async {
+ isLoading = true
+ errorMessage = nil
+ do {
+ items = try await KnowledgeItemService.shared.list(knowledgeBaseId: knowledgeBaseId)
+ } catch {
+ errorMessage = "加载知识点失败"
+ }
+ isLoading = false
+ }
+
+ func loadKnowledgeBase(id: String) async {
+ do {
+ knowledgeBase = try await KnowledgeBaseService.shared.detail(id: id)
+ } catch {
+ errorMessage = "加载知识库详情失败"
+ }
+ }
+
+ func addItem(knowledgeBaseId: String, title: String, content: String?) async -> KnowledgeItem? {
+ do {
+ let item = try await KnowledgeItemService.shared.create(
+ knowledgeBaseId: knowledgeBaseId, title: title, content: content
+ )
+ items.append(item)
+ return item
+ } catch {
+ errorMessage = "添加知识点失败"
+ return nil
+ }
+ }
+}
diff --git a/AIStudyApp/AIStudyApp/Features/Profile/EditProfilePage.swift b/AIStudyApp/AIStudyApp/Features/Profile/EditProfilePage.swift
new file mode 100644
index 0000000..61bae88
--- /dev/null
+++ b/AIStudyApp/AIStudyApp/Features/Profile/EditProfilePage.swift
@@ -0,0 +1,113 @@
+import SwiftUI
+
+struct EditProfilePage: View {
+ @StateObject private var viewModel = ProfileViewModel()
+ @State private var nickname: String = ""
+ @State private var learningIdentity: String = ""
+ @State private var learningDirection: String = ""
+ @State private var bio: String = ""
+ @State private var currentGoal: String = ""
+ @State private var saved = false
+
+ var body: some View {
+ ZStack {
+ Color.zxBg0.ignoresSafeArea()
+ ScrollView {
+ VStack(spacing: 16) {
+ sectionHeader("基本信息")
+ VStack(spacing: 0) {
+ ZXEditField(title: "昵称", text: $nickname, placeholder: "你的昵称")
+ ZXSettingDivider()
+ ZXEditField(title: "学习身份", text: $learningIdentity, placeholder: "如:考研学生、软件工程师")
+ }
+ .background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
+ .overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
+
+ sectionHeader("学习档案")
+ VStack(spacing: 0) {
+ ZXEditField(title: "学习方向", text: $learningDirection, placeholder: "如:机器学习、公考申论")
+ ZXSettingDivider()
+ ZXEditField(title: "当前目标", text: $currentGoal, placeholder: "如:通过 6 月 CFA 一级")
+ }
+ .background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
+ .overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
+
+ sectionHeader("个人简介")
+ VStack(spacing: 0) {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("简介").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035)
+ TextEditor(text: $bio)
+ .frame(minHeight: 100)
+ .scrollContentBackground(.hidden)
+ .padding(12)
+ .background(Color.zxFill003)
+ .clipShape(RoundedRectangle(cornerRadius: 14))
+ .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
+ .tint(Color.zxPurple)
+ }.padding(.horizontal, 16).padding(.vertical, 14)
+ }
+ .background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
+ .overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
+
+ Button {
+ Task {
+ _ = try? await UserService.shared.updateProfile(UpdateProfileRequest(
+ nickname: nickname.isEmpty ? nil : nickname, avatarUrl: nil
+ ))
+ _ = try? await UserService.shared.updateProfileDetail(UpdateProfileDataRequest(
+ learningIdentity: learningIdentity.isEmpty ? nil : learningIdentity,
+ learningDirection: learningDirection.isEmpty ? nil : learningDirection,
+ bio: bio.isEmpty ? nil : bio,
+ currentGoal: currentGoal.isEmpty ? nil : currentGoal
+ ))
+ saved = true
+ }
+ } label: {
+ Text(saved ? "已保存" : "保存修改")
+ .font(.system(size: 14, weight: .bold))
+ .foregroundColor(.white)
+ .frame(maxWidth: .infinity)
+ .frame(height: 52)
+ .background(ZXGradient.ctaPurple)
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+ }
+ }
+ .padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80)
+ }
+ .scrollIndicators(.hidden)
+ }
+ .navigationTitle("编辑资料")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbarBackground(.hidden, for: .navigationBar)
+ .task {
+ await viewModel.loadProfile()
+ nickname = viewModel.userProfile?.nickname ?? ""
+ learningIdentity = viewModel.profileData?.learningIdentity ?? ""
+ learningDirection = viewModel.profileData?.learningDirection ?? ""
+ bio = viewModel.profileData?.bio ?? ""
+ currentGoal = viewModel.profileData?.currentGoal ?? ""
+ }
+ }
+
+ private func sectionHeader(_ text: String) -> some View {
+ Text(text).font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5).padding(.top, 4)
+ }
+}
+
+struct ZXEditField: View {
+ let title: String
+ @Binding var text: String
+ let placeholder: String
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0).frame(width: 72, alignment: .leading)
+ TextField(placeholder, text: $text)
+ .font(.system(size: 14))
+ .tint(Color.zxPurple)
+ .foregroundColor(Color.zxF0)
+ Spacer()
+ }
+ .padding(.horizontal, 16).padding(.vertical, 14)
+ }
+}
diff --git a/AIStudyApp/AIStudyApp/Features/Profile/NotificationListView.swift b/AIStudyApp/AIStudyApp/Features/Profile/NotificationListView.swift
index 1043c02..dd55ee8 100644
--- a/AIStudyApp/AIStudyApp/Features/Profile/NotificationListView.swift
+++ b/AIStudyApp/AIStudyApp/Features/Profile/NotificationListView.swift
@@ -1,7 +1,7 @@
import SwiftUI
struct NotificationListView: View {
- @State private var notifications: [NotificationItem] = [
+ @State private var notifications: [ZXNotificationRowData] = [
.init(type: "review", title: "复习提醒", content: "你有 8 个知识点需要复习", time: "刚刚", read: false),
.init(type: "ai", title: "AI 分析完成", content: "\"机器学习基础\"薄弱点分析已完成", time: "1小时前", read: false),
.init(type: "streak", title: "学习成就", content: "恭喜!你已连续学习 14 天 🔥", time: "昨天", read: true),
@@ -38,7 +38,7 @@ struct NotificationListView: View {
}
}
-struct NotificationItem: Identifiable {
+struct ZXNotificationRowData: Identifiable {
let id = UUID()
let type: String
let title: String
@@ -48,7 +48,7 @@ struct NotificationItem: Identifiable {
}
struct ZXNotificationRow: View {
- let item: NotificationItem
+ let item: ZXNotificationRowData
let onTap: () -> Void
private var iconName: String {
diff --git a/AIStudyApp/AIStudyApp/Features/Profile/ProfileView.swift b/AIStudyApp/AIStudyApp/Features/Profile/ProfileView.swift
index 1ea79e2..1c06369 100644
--- a/AIStudyApp/AIStudyApp/Features/Profile/ProfileView.swift
+++ b/AIStudyApp/AIStudyApp/Features/Profile/ProfileView.swift
@@ -1,7 +1,10 @@
import SwiftUI
struct ProfileView: View {
+ @StateObject private var viewModel = ProfileViewModel()
+
var body: some View {
+ let _ = Task { if viewModel.userProfile == nil { await viewModel.loadAll() } }
ZStack {
ZXGradient.page.ignoresSafeArea()
ScrollView {
@@ -46,14 +49,18 @@ struct ProfileView: View {
}
}
private var profileCard: some View {
- NavigationLink(destination: SettingsView()) {
+ let profile = viewModel.userProfile
+ return NavigationLink(destination: EditProfilePage()) {
VStack(spacing: 16) {
HStack {
ZStack { Circle().frame(width: 80, height: 80).foregroundColor(Color.zxPurpleBG(0.2)); Text("🧑🎓").font(.system(size: 36)) }
- VStack(alignment: .leading, spacing: 4) { Text("学习者").font(.system(size: 20, weight: .bold)).foregroundColor(Color.zxF0); Text("user@example.com").font(.system(size: 12)).foregroundColor(Color.zxF04) }
+ VStack(alignment: .leading, spacing: 4) {
+ Text(profile?.nickname ?? "学习者").font(.system(size: 20, weight: .bold)).foregroundColor(Color.zxF0)
+ Text(profile?.email ?? "").font(.system(size: 12)).foregroundColor(Color.zxF04)
+ }
Spacer(); Image(systemName: "chevron.right").font(.system(size: 14)).foregroundColor(Color.zxF03)
}
- HStack(spacing: 0) { ZXProfileStat(value: "14", label: "连续天", color: Color.zxOrange); ZXProfileStat(value: "47", label: "知识点", color: Color.zxPurple); ZXProfileStat(value: "1,240", label: "积分", color: Color.zxTeal) }
+ HStack(spacing: 0) { ZXProfileStat(value: "\(viewModel.summary?.activeDays ?? 0)", label: "活跃天", color: Color.zxOrange); ZXProfileStat(value: "\(viewModel.summary?.totalCardsReviewed ?? 0)", label: "复习卡片", color: Color.zxPurple); ZXProfileStat(value: "\(viewModel.summary?.totalMinutes ?? 0)", label: "分钟", color: Color.zxTeal) }
}.padding(20).background(ZXGradient.profileCard).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "#7C6EFA", opacity: 0.2), lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20))
}.foregroundColor(.primary)
}
diff --git a/AIStudyApp/AIStudyApp/Features/Profile/ProfileViewModel.swift b/AIStudyApp/AIStudyApp/Features/Profile/ProfileViewModel.swift
new file mode 100644
index 0000000..ae54c34
--- /dev/null
+++ b/AIStudyApp/AIStudyApp/Features/Profile/ProfileViewModel.swift
@@ -0,0 +1,58 @@
+import Foundation
+import Combine
+
+@MainActor
+class ProfileViewModel: ObservableObject {
+ @Published var userProfile: UserProfileResponse?
+ @Published var preferences: UserPreferences?
+ @Published var profileData: UserProfileData?
+ @Published var summary: ActivitySummary?
+ @Published var isLoading = false
+ @Published var errorMessage: String?
+
+ func loadAll() async {
+ isLoading = true
+ errorMessage = nil
+ async let _ = loadProfile()
+ async let _ = loadActivitySummary()
+ isLoading = false
+ }
+
+ func loadProfile() async {
+ isLoading = true
+ errorMessage = nil
+ do {
+ let profile = try await UserService.shared.myProfile()
+ userProfile = profile
+ preferences = profile.preferences
+ profileData = profile.profile
+ } catch {
+ errorMessage = "加载用户信息失败"
+ }
+ isLoading = false
+ }
+
+ func updatePreferences(_ dto: UpdatePreferencesRequest) async {
+ do {
+ preferences = try await UserService.shared.updatePreferences(dto)
+ } catch {
+ errorMessage = "保存设置失败"
+ }
+ }
+
+ func updateProfileDetail(_ dto: UpdateProfileDataRequest) async {
+ do {
+ profileData = try await UserService.shared.updateProfileDetail(dto)
+ } catch {
+ errorMessage = "保存学习档案失败"
+ }
+ }
+
+ private func loadActivitySummary() async {
+ do {
+ summary = try await ActivityService.shared.summary()
+ } catch {
+ // non-critical, stats remain at 0
+ }
+ }
+}
diff --git a/AIStudyApp/AIStudyApp/Features/Profile/SettingsView.swift b/AIStudyApp/AIStudyApp/Features/Profile/SettingsView.swift
index e425238..9ea4c4c 100644
--- a/AIStudyApp/AIStudyApp/Features/Profile/SettingsView.swift
+++ b/AIStudyApp/AIStudyApp/Features/Profile/SettingsView.swift
@@ -1,26 +1,38 @@
import SwiftUI
struct SettingsView: View {
- @State private var language = "zh-Hans"
- @AppStorage("appAppearance") private var appearance = "system"
+ @EnvironmentObject var authManager: AuthManager
+ @StateObject private var profileVM = ProfileViewModel()
+ @State private var language = "zh-CN"
+ @State private var appearance = "system"
+ @State private var defaultFocusMinutes = 25
+ @State private var notificationEnabled = true
@State private var reviewReminder = true
@State private var reminderTime = "20:00"
@State private var intervalDays = "1"
@State private var iCloudSync = false
@State private var autoBackup = false
+ @State private var showLogoutAlert = false
var body: some View {
ZStack {
Color.zxBg0.ignoresSafeArea()
ScrollView {
+ let _ = Task { await profileVM.loadProfile(); if let p = profileVM.preferences {
+ appearance = p.appearance ?? "system"
+ language = p.language ?? "zh-CN"
+ defaultFocusMinutes = p.defaultFocusMinutes ?? 25
+ notificationEnabled = p.notificationEnabled ?? true
+ reviewReminder = notificationEnabled
+ } }
VStack(spacing: 16) {
sectionHeader("外观与语言")
VStack(spacing: 0) {
ZXSettingRow(title: "外观", value: appearanceLabel, icon: "moon.stars.fill", color: Color.zxPurple)
.contentShape(Rectangle())
- .onTapGesture { toggleAppearance() }
+ .onTapGesture { cycleAppearance() }
ZXSettingDivider()
- ZXSettingRow(title: "语言", value: "简体中文", icon: "globe", color: Color.zxTeal)
+ ZXSettingRow(title: "语言", value: language == "zh-CN" ? "简体中文" : "English", icon: "globe", color: Color.zxTeal)
}
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
@@ -72,6 +84,28 @@ struct SettingsView: View {
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
+ VStack(spacing: 0) {
+ Button {
+ showLogoutAlert = true
+ } label: {
+ HStack(spacing: 12) {
+ Image(systemName: "rectangle.portrait.and.arrow.right").font(.system(size: 16)).foregroundColor(.red).frame(width: 32, height: 32).background(Color.red.opacity(0.12)).clipShape(RoundedRectangle(cornerRadius: 8))
+ Text("退出登录").font(.system(size: 14, weight: .semibold)).foregroundColor(.red)
+ Spacer()
+ }.padding(.horizontal, 16).padding(.vertical, 14)
+ }
+ }
+ .background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
+ .overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
+ .alert("退出登录", isPresented: $showLogoutAlert) {
+ Button("取消", role: .cancel) {}
+ Button("退出", role: .destructive) {
+ Task { await authManager.signOut() }
+ }
+ } message: {
+ Text("退出后需要重新登录")
+ }
+
HStack(spacing: 4) {
Text("知习 v1.0").font(.system(size: 12)).foregroundColor(Color.zxF03)
}.padding(.bottom, 100)
@@ -90,12 +124,29 @@ struct SettingsView: View {
switch appearance { case "system": return "跟随系统"; case "dark": return "深色模式"; default: return "浅色模式" }
}
- private func toggleAppearance() {
+ private func cycleAppearance() {
switch appearance {
case "system": appearance = "dark"
case "dark": appearance = "light"
default: appearance = "system"
}
+ Task {
+ await profileVM.updatePreferences(UpdatePreferencesRequest(
+ preferredMethods: nil, defaultFocusMinutes: defaultFocusMinutes,
+ aiSuggestionLevel: nil, language: language, appearance: appearance,
+ notificationEnabled: notificationEnabled
+ ))
+ }
+ }
+
+ private func saveNotificationSettings() {
+ Task {
+ await profileVM.updatePreferences(UpdatePreferencesRequest(
+ preferredMethods: nil, defaultFocusMinutes: defaultFocusMinutes,
+ aiSuggestionLevel: nil, language: language, appearance: appearance,
+ notificationEnabled: notificationEnabled
+ ))
+ }
}
}
@@ -127,7 +178,13 @@ struct GoalSettingDetailView: View {
Text("每日学习时间").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035)
HStack(spacing: 8) { ForEach(times, id: \.self) { t in let sel = dailyMins == t; Button { dailyMins = t } label: { Text(t).font(.system(size: 12)).fontWeight(sel ? .semibold : .regular).foregroundColor(sel ? Color.zxPurple : Color.zxF05).frame(maxWidth: .infinity).frame(height: 40).background(sel ? Color(hex: "#7C6EFA", opacity: 0.1) : Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 12)) }.foregroundColor(.primary) } }
}
- Button {} label: {
+ Button {
+ Task {
+ _ = try? await UserService.shared.updateProfileDetail(UpdateProfileDataRequest(
+ learningIdentity: nil, learningDirection: nil, bio: nil, currentGoal: selectedGoal
+ ))
+ }
+ } label: {
Text("保存").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16))
}
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80)
@@ -139,6 +196,7 @@ struct GoalSettingDetailView: View {
struct MethodPreferenceView: View {
@State private var methods: Set = ["间隔回忆", "费曼技巧"]
let allMethods = ["间隔回忆", "费曼技巧", "AI 分析", "主动回忆"]
+ @State private var saved = false
var body: some View {
ZStack {
@@ -157,8 +215,17 @@ struct MethodPreferenceView: View {
}.foregroundColor(.primary)
}
}
- Button {} label: {
- Text("保存").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16))
+ Button {
+ Task {
+ _ = try? await UserService.shared.updatePreferences(UpdatePreferencesRequest(
+ preferredMethods: Array(methods), defaultFocusMinutes: nil,
+ aiSuggestionLevel: nil, language: nil, appearance: nil,
+ notificationEnabled: nil
+ ))
+ saved = true
+ }
+ } label: {
+ Text(saved ? "已保存" : "保存").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16))
}
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80)
}.scrollIndicators(.hidden)
@@ -169,6 +236,7 @@ struct MethodPreferenceView: View {
struct FeedbackFormView: View {
@State private var type = "功能建议"
@State private var content = ""
+ @State private var submitted = false
let types = ["Bug 反馈", "功能建议", "内容问题", "其他"]
var body: some View {
@@ -184,8 +252,13 @@ struct FeedbackFormView: View {
Text("内容").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035)
TextEditor(text: $content).frame(minHeight: 150).scrollContentBackground(.hidden).padding(12).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
}
- Button {} label: {
- Text("提交").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16))
+ Button {
+ Task {
+ _ = try? await FeedbackService.shared.submit(category: type, content: content)
+ submitted = true
+ }
+ } label: {
+ Text(submitted ? "已提交" : "提交").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16))
}
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80)
}.scrollIndicators(.hidden)
diff --git a/AIStudyApp/AIStudyApp/Features/Study/ReviewCardView.swift b/AIStudyApp/AIStudyApp/Features/Study/ReviewCardView.swift
index cbdd804..04082a5 100644
--- a/AIStudyApp/AIStudyApp/Features/Study/ReviewCardView.swift
+++ b/AIStudyApp/AIStudyApp/Features/Study/ReviewCardView.swift
@@ -1,6 +1,7 @@
import SwiftUI
struct ReviewCardView: View {
+ @StateObject private var viewModel = ReviewViewModel()
let cards: [ReviewCardItem] = [
.init(question: "什么是偏差(Bias)和方差(Variance)的权衡?",
answer: "偏差衡量模型预测与真实值的偏离程度,方差衡量模型在不同训练集上的预测波动。偏差-方差权衡指的是:简单模型偏差高方差低(欠拟合),复杂模型偏差低方差高(过拟合)。最佳模型需要在两者之间取得平衡。",
@@ -39,6 +40,7 @@ struct ReviewCardView: View {
}
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
+ .task { await viewModel.loadDueCards() }
}
private var progressBar: some View {
diff --git a/AIStudyApp/AIStudyApp/Features/Study/ReviewViewModel.swift b/AIStudyApp/AIStudyApp/Features/Study/ReviewViewModel.swift
new file mode 100644
index 0000000..5640955
--- /dev/null
+++ b/AIStudyApp/AIStudyApp/Features/Study/ReviewViewModel.swift
@@ -0,0 +1,48 @@
+import Combine
+import Foundation
+
+@MainActor
+class ReviewViewModel: ObservableObject {
+ @Published var dueCards: [ReviewCard] = []
+ @Published var currentCardIndex = 0
+ @Published var isLoading = false
+ @Published var errorMessage: String?
+
+ var currentCard: ReviewCard? {
+ guard currentCardIndex < dueCards.count else { return nil }
+ return dueCards[currentCardIndex]
+ }
+
+ var isComplete: Bool {
+ currentCardIndex >= dueCards.count
+ }
+
+ var progress: Double {
+ guard !dueCards.isEmpty else { return 0 }
+ return Double(currentCardIndex) / Double(dueCards.count)
+ }
+
+ func loadDueCards() async {
+ isLoading = true
+ errorMessage = nil
+ do {
+ dueCards = try await ReviewService.shared.dueCards()
+ currentCardIndex = 0
+ } catch {
+ errorMessage = "加载复习卡片失败"
+ }
+ isLoading = false
+ }
+
+ func submitRating(_ rating: String, responseText: String? = nil) async {
+ guard let card = currentCard else { return }
+ do {
+ _ = try await ReviewService.shared.submit(
+ id: card.id, rating: rating, responseText: responseText
+ )
+ currentCardIndex += 1
+ } catch {
+ errorMessage = "提交复习结果失败"
+ }
+ }
+}
diff --git a/AIStudyApp/AIStudyApp/Features/Study/StudyHomeView.swift b/AIStudyApp/AIStudyApp/Features/Study/StudyHomeView.swift
index 89f514e..d7a0022 100644
--- a/AIStudyApp/AIStudyApp/Features/Study/StudyHomeView.swift
+++ b/AIStudyApp/AIStudyApp/Features/Study/StudyHomeView.swift
@@ -1,6 +1,8 @@
import SwiftUI
struct StudyHomeView: View {
+ @StateObject private var studyVM = StudyViewModel()
+ @StateObject private var reviewVM = ReviewViewModel()
@State private var ts: [ZXSTask] = [
.init(t: "机器学习 - 回忆测试", tp: "回忆测试", c: Color.zxPurple, m: 10, d: true),
.init(t: "高数 - 间隔复习 8 题", tp: "间隔复习", c: Color.zxOrange, m: 15, d: true),
@@ -39,6 +41,7 @@ struct StudyHomeView: View {
.padding(.bottom, 120) }
.padding(.horizontal, 20) }
.scrollIndicators(.hidden) }
+ .task { await studyVM.loadSessions() }
}
private var pc: some View { let dn = ts.filter(\.d).count; let pct = CGFloat(dn) / 5
return VStack(spacing: 12) { HStack { VStack(alignment: .leading, spacing: 2) { Text("今日进度").font(.system(size: 13, weight: .medium)).foregroundColor(Color.zxF05); HStack(alignment: .lastTextBaseline, spacing: 6) { Text("\(dn)").font(.system(size: 26, weight: .black)).foregroundColor(Color.zxF0); Text("/ 5"); Text("个任务").font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxF04) } }; Spacer()
diff --git a/AIStudyApp/AIStudyApp/Features/Study/StudyViewModel.swift b/AIStudyApp/AIStudyApp/Features/Study/StudyViewModel.swift
new file mode 100644
index 0000000..d686629
--- /dev/null
+++ b/AIStudyApp/AIStudyApp/Features/Study/StudyViewModel.swift
@@ -0,0 +1,42 @@
+import Combine
+import Foundation
+
+@MainActor
+class StudyViewModel: ObservableObject {
+ @Published var sessions: [LearningSession] = []
+ @Published var currentSession: LearningSession?
+ @Published var isLoading = false
+ @Published var errorMessage: String?
+
+ func loadSessions() async {
+ isLoading = true
+ errorMessage = nil
+ do {
+ sessions = try await LearningSessionService.shared.list()
+ } catch {
+ errorMessage = "加载学习会话失败"
+ }
+ isLoading = false
+ }
+
+ func startSession(knowledgeBaseId: String? = nil, mode: String? = nil) async {
+ do {
+ currentSession = try await LearningSessionService.shared.start(
+ knowledgeBaseId: knowledgeBaseId, mode: mode
+ )
+ sessions.insert(currentSession!, at: 0)
+ } catch {
+ errorMessage = "开始学习会话失败"
+ }
+ }
+
+ func endCurrentSession() async {
+ guard let session = currentSession else { return }
+ do {
+ _ = try await LearningSessionService.shared.end(id: session.id)
+ currentSession = nil
+ } catch {
+ errorMessage = "结束学习会话失败"
+ }
+ }
+}