feat(ios): 鉴权系统完善 + 前后端打通 + 模型对齐 + ViewModel 创建
- 新增 AuthManager (ObservableObject) 集中管理鉴权状态: - session 恢复 → token 验证 → 自动刷新 - 登出自动重定向到登录页 - NotificationCenter 监听 401 实现全局踢回 - APIClient 新增 401 自动 refresh + 单次重试 - App.swift 重构鉴权门控: - 去掉 hasCompletedOnboarding 绕过鉴权漏洞 - 拆分为 SplashScreen / PreLoginFlow / PostLoginOnboardingFlow / ContentView - LoginPage 移除"跳过"按钮 - KeychainHelper 实现 token 安全存储 - APIModels 对齐后端 Prisma schema (UserProfile/KnowledgeBase/ReviewCard 等) - APIService 简化 AuthService,token 管理迁移至 AuthManager - 新增 8 个 ViewModel 接入 API: ProfileViewModel, LibraryViewModel, StudyViewModel, ActiveRecallViewModel, AIAnalysisViewModel, ReviewViewModel, ActivityViewModel - 新增 EditProfilePage 编辑资料页 - 新增 NotificationListView 通知列表页 - AIHomeView 修复"检测中"卡住 (改用公开 GET / 健康检查) - SettingsView 登出调用 AuthManager.signOut() 实现重定向 - 修复 NotificationItem 命名冲突、Combine import 缺失等编译错误 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
dc4ad424e2
commit
5e19bd740e
10
AIStudyApp/AIStudyApp.entitlements
Normal file
10
AIStudyApp/AIStudyApp.entitlements
Normal file
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.applesignin</key>
|
||||
<array>
|
||||
<string>Default</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@ -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;
|
||||
|
||||
@ -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<ASAuthorization, Error>) {
|
||||
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)
|
||||
} } }
|
||||
}
|
||||
}
|
||||
|
||||
103
AIStudyApp/AIStudyApp/Core/Auth/AuthManager.swift
Normal file
103
AIStudyApp/AIStudyApp/Core/Auth/AuthManager.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,20 +1,41 @@
|
||||
//
|
||||
// Models.swift - 对应 api-server 的所有 DTO
|
||||
// APIModels.swift - 对齐 api-server 实际返回结构
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - API Envelope (ResponseInterceptor wraps all responses)
|
||||
|
||||
struct APIEnvelope<T: Decodable>: 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<T: Decodable>: 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?
|
||||
}
|
||||
|
||||
@ -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<T: Decodable>(
|
||||
_ 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<T>.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
|
||||
|
||||
62
AIStudyApp/AIStudyApp/Core/Security/KeychainHelper.swift
Normal file
62
AIStudyApp/AIStudyApp/Core/Security/KeychainHelper.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
24
AIStudyApp/AIStudyApp/Features/AI/AIAnalysisViewModel.swift
Normal file
24
AIStudyApp/AIStudyApp/Features/AI/AIAnalysisViewModel.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
@ -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 = ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) }
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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:"高")
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
113
AIStudyApp/AIStudyApp/Features/Profile/EditProfilePage.swift
Normal file
113
AIStudyApp/AIStudyApp/Features/Profile/EditProfilePage.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<String> = ["间隔回忆", "费曼技巧"]
|
||||
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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
48
AIStudyApp/AIStudyApp/Features/Study/ReviewViewModel.swift
Normal file
48
AIStudyApp/AIStudyApp/Features/Study/ReviewViewModel.swift
Normal file
@ -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 = "提交复习结果失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
42
AIStudyApp/AIStudyApp/Features/Study/StudyViewModel.swift
Normal file
42
AIStudyApp/AIStudyApp/Features/Study/StudyViewModel.swift
Normal file
@ -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 = "结束学习会话失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user