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 = "结束学习会话失败" + } + } +}