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:
WangDL 2026-05-17 19:06:23 +08:00
parent dc4ad424e2
commit 5e19bd740e
27 changed files with 1478 additions and 333 deletions

View 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>

View File

@ -247,6 +247,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = AIStudyApp.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 88FMP9VK6T; DEVELOPMENT_TEAM = 88FMP9VK6T;
@ -282,6 +283,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = AIStudyApp.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 88FMP9VK6T; DEVELOPMENT_TEAM = 88FMP9VK6T;

View File

@ -1,9 +1,11 @@
import SwiftUI import SwiftUI
import AuthenticationServices
@main @main
struct AIStudyAppApp: App { struct AIStudyAppApp: App {
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
@AppStorage("appAppearance") private var appAppearance = "system" @AppStorage("appAppearance") private var appAppearance = "system"
@StateObject private var authManager = AuthManager()
private var effectiveColorScheme: ColorScheme? { private var effectiveColorScheme: ColorScheme? {
switch appAppearance { switch appAppearance {
@ -15,102 +17,251 @@ struct AIStudyAppApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
if hasCompletedOnboarding { Group {
ContentView().preferredColorScheme(effectiveColorScheme) if authManager.isRestoring {
} else { SplashScreen()
OnboardingFlowView(hasCompletedOnboarding: $hasCompletedOnboarding) } else if authManager.isAuthenticated {
.preferredColorScheme(effectiveColorScheme) 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 @Binding var hasCompletedOnboarding: Bool
@State private var step = 0 @State private var step = 0
var body: some View { var body: some View {
ZStack { ZStack {
switch step { switch step {
case 0: SplashPage { withAnimation(.easeInOut(duration: 0.5)) { step = 1 } } case 0:
case 1: WelcomePage { withAnimation { step = 2 } } onSkip: { hasCompletedOnboarding = true } OnboardingPage { withAnimation { step = 1 } }
case 2: LoginPage { step = 3 } onSkip: { hasCompletedOnboarding = true } case 1:
case 3: OnboardingPage { step = 4 } GoalSetupPage { _ in hasCompletedOnboarding = true }
case 4: GoalSetupPage { $0 ? (hasCompletedOnboarding = true) : (step = 0) } default:
default: EmptyView() EmptyView()
} }
} }
} }
} }
// Splash // MARK: - Welcome
struct SplashPage: View {
let onFinish: () -> Void struct WelcomePage: View {
let onContinue: () -> Void
var body: some View { var body: some View {
ZStack { ZStack {
LinearGradient(colors: [Color(hex: "#0D0D20"), Color(hex: "#0F0F1A"), Color(hex: "#130D20")], startPoint: .top, endPoint: .bottom).ignoresSafeArea() ZXGradient.page.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: "#7C6EFA", opacity: 0.12), .clear], center: .topTrailing, startRadius: 0, endRadius: 260)).frame(width: 260, height: 260).offset(x: 80, y: -120).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 { Spacer()
VStack(spacing: 0) { VStack(spacing: 14) {
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) 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("知习").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("用 AI 重新定义\n你的学习方式").font(.system(size: 32, weight: .heavy)).tracking(-0.8).lineSpacing(4)
Text("Z H I X I").font(.system(size: 13, weight: .medium)).foregroundColor(Color(hex: "#F0F0FF", opacity: 0.4)).tracking(3).padding(.top, 6) VStack(spacing: 10) {
Text("AI-first 系统化学习").font(.system(size: 14)).foregroundColor(Color(hex: "#F0F0FF", opacity: 0.45)).tracking(0.5).padding(.top, 24) 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) } VStack(spacing: 12) {
}.onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { onFinish() } } 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 // MARK: - Login
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) struct LoginPage: View {
VStack { Spacer() @EnvironmentObject var authManager: AuthManager
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()) @State private var isLoggingIn = false
Text("用 AI 重新定义\n你的学习方式").font(.system(size: 32, weight: .heavy)).tracking(-0.8).lineSpacing(4) @State private var errorMessage: String?
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) } } var body: some View {
} ZStack {
struct FeatureRow: View { let icon: String; let title: String; let desc: String ZXGradient.page.ignoresSafeArea()
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)) } 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 // MARK: - Shared UI components
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) } } }
}
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 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 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)) } } } 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 // MARK: - Onboarding
struct OnboardingPage: View { let onContinue: () -> Void; @State private var step = 0
struct OnboardingPage: View {
let onContinue: () -> Void
@State private var step = 0
let titles = ["输入知识", "主动输出", "AI 分析", "掌握知识"] let titles = ["输入知识", "主动输出", "AI 分析", "掌握知识"]
let descs = ["从任何地方收集并导入学习资料,构建你的专属知识库。", "通过间隔回忆和费曼解释法,将知识转化为长期记忆。", "AI 自动定位薄弱知识点,给出针对性的学习建议。", "系统性掌握每一个知识点,建立牢固的知识体系。"] let descs = ["从任何地方收集并导入学习资料,构建你的专属知识库。", "通过间隔回忆和费曼解释法,将知识转化为长期记忆。", "AI 自动定位薄弱知识点,给出针对性的学习建议。", "系统性掌握每一个知识点,建立牢固的知识体系。"]
var body: some View { ZStack { ZXGradient.page.ignoresSafeArea()
VStack(spacing: 0) { Spacer() var body: some View {
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) } } ZStack {
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) ZXGradient.page.ignoresSafeArea()
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) } VStack(spacing: 0) { Spacer()
Button("跳过") { onContinue() }.font(.system(size: 12)).foregroundColor(Color.zxF03).padding(.top, 12).padding(.bottom, 32) } }.padding(.horizontal, 20) } 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 // MARK: - GoalSetup
struct GoalSetupPage: View { let onComplete: (Bool) -> Void
@State private var selectedGoal = ""; let goals = [("🧑‍🎓","备考考试","公考、考研、考证等"),("💼","职业技能","编程、设计、产品等"),("📚","通识学习","扩充知识面"),("🎯","自定义","设定自己的目标")] struct GoalSetupPage: View {
@State private var selectedMethod = ""; let methods = ["间隔回忆","费曼技巧","AI 分析"] let onComplete: (Bool) -> Void
@State private var dailyMins = "30 分钟"; let times = ["15 分钟","30 分钟","1 小时","不限制"] @State private var selectedGoal = ""
var body: some View { ZStack { ZXGradient.page.ignoresSafeArea() 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() VStack(spacing: 0) { Spacer()
Text("设定你的学习目标").font(.system(size: 24, weight: .heavy)).tracking(-0.5).foregroundColor(Color.zxF0).padding(.bottom, 24) Text("设定你的学习目标").font(.system(size: 24, weight: .heavy)).tracking(-0.5).foregroundColor(Color.zxF0).padding(.bottom, 24)
ScrollView { VStack(spacing: 16) { 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) 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) } } } 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) 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) } } } } } 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) } } } 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)
} } }
}
} }

View 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
}
}
}

View File

@ -1,20 +1,41 @@
// //
// Models.swift - api-server DTO // APIModels.swift - api-server
// //
import Foundation 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 // MARK: - Waitlist
struct WaitlistEntry: Codable, Identifiable { struct WaitlistEntry: Codable, Identifiable {
let id: String let id: String
let email: String
let nickname: String? let nickname: String?
let email: String
let devices: [String]? let devices: [String]?
let interests: [String]? let interests: [String]?
let painpoint: String? let painpoint: String?
let willingBeta: Bool? let willingBeta: Bool?
let createdAt: String let createdAt: String?
} }
struct WaitlistCreateRequest: Codable { struct WaitlistCreateRequest: Codable {
@ -37,7 +58,7 @@ struct WaitlistCreateRequest: Codable {
} }
struct WaitlistResponse: Codable { struct WaitlistResponse: Codable {
let success: Bool let success: Bool?
let message: String? let message: String?
let data: WaitlistEntry? let data: WaitlistEntry?
} }
@ -47,29 +68,24 @@ struct WaitlistStats: Codable {
let today: Int? let today: Int?
let deviceBreakdown: [String: Int]? let deviceBreakdown: [String: Int]?
let interestBreakdown: [String: Int]? let interestBreakdown: [String: Int]?
enum CodingKeys: String, CodingKey {
case total, today
case deviceBreakdown = "deviceBreakdown"
case interestBreakdown = "interestBreakdown"
}
} }
// MARK: - Auth // MARK: - Auth
struct AuthResponse: Codable { struct AuthResponse: Codable {
let success: Bool
let data: AuthTokens?
}
struct AuthTokens: Codable {
let accessToken: String let accessToken: String
let refreshToken: String? let refreshToken: String?
let expiresIn: Int? let user: AuthUser?
}
enum CodingKeys: String, CodingKey { struct AuthUser: Codable, Identifiable {
case accessToken, refreshToken, expiresIn let id: String
} let email: String?
let nickname: String?
let avatarUrl: String?
let role: String?
let status: String?
let onboardingCompleted: Bool?
} }
struct AppleAuthRequest: Codable { struct AppleAuthRequest: Codable {
@ -82,47 +98,70 @@ struct AppleAuthRequest: Codable {
} }
} }
// MARK: - User struct RefreshRequest: Codable {
let refreshToken: String
struct UserProfileResponse: Codable {
let success: Bool
let data: UserProfileData?
} }
struct UserProfileData: Codable, Identifiable { // MARK: - User Profile (matches GET /users/me with include: profile + preferences)
struct UserProfileResponse: Codable, Identifiable {
let id: String let id: String
let email: String let email: String?
let nickname: String? let nickname: String?
let avatar: String? let avatarUrl: String?
let preferences: UserPreferences? let role: String?
let stats: UserStats? let status: String?
let onboardingCompleted: Bool?
let createdAt: String? 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 { struct UserPreferences: Codable {
let dailyGoal: Int? let preferredMethods: [String]?
let reminderTime: String? let defaultFocusMinutes: Int?
let theme: String? let aiSuggestionLevel: String?
let language: String?
let appearance: String?
let notificationEnabled: Bool?
} }
struct UserStats: Codable { struct UpdateProfileRequest: Codable {
let totalLearningDays: Int?
let completedCourses: Int?
let totalMinutes: Int?
}
struct UpdateUserRequest: Codable {
let nickname: String? 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 { struct KnowledgeBase: Codable, Identifiable {
let id: String let id: String
let userId: String? let userId: String?
let title: String let title: String
let description: String? let description: String?
let coverKey: String?
let status: String? let status: String?
let itemCount: Int? let itemCount: Int?
let lastStudiedAt: String? let lastStudiedAt: String?
@ -130,184 +169,210 @@ struct KnowledgeBase: Codable, Identifiable {
let updatedAt: String? let updatedAt: String?
} }
typealias KnowledgeBaseListResponse = [KnowledgeBase]
struct CreateKnowledgeBaseRequest: Codable { struct CreateKnowledgeBaseRequest: Codable {
let name: String let title: String
let description: String? let description: String?
let icon: String?
} }
// MARK: - Knowledge Items // MARK: - Knowledge Items (matches Prisma KnowledgeItem model)
struct KnowledgeItem: Codable, Identifiable { struct KnowledgeItem: Codable, Identifiable {
let id: String let id: String
let userId: String?
let knowledgeBaseId: String?
let parentId: String?
let itemType: String?
let title: String let title: String
let content: String? let content: String?
let baseId: String? let summary: String?
let tags: [String]? let sourceType: String?
let mastery: Double? let sourceRef: String?
let orderIndex: Int?
let status: String? let status: String?
let createdAt: String? let createdAt: String?
let updatedAt: 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]?
} }
struct CreateKnowledgeItemRequest: Codable { struct CreateKnowledgeItemRequest: Codable {
let knowledgeBaseId: String
let title: String let title: String
let content: String? let content: String?
let baseId: String let itemType: String?
let tags: [String]?
enum CodingKeys: String, CodingKey {
case title, content, tags
case baseId = "baseId"
}
} }
// 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 { struct AIAnalysisRequest: Codable {
let text: String let questionText: String?
let type: String let knowledgeItemContent: String?
let context: AIAnalysisContext? let userAnswer: String?
let text: String?
struct AIAnalysisContext: Codable { let type: String?
let knowledgeBaseIds: [String]?
let focusItemIds: [String]?
}
}
struct AIAnalysisResponse: Codable {
let success: Bool
let data: AIAnalysisResult?
} }
struct AIAnalysisResult: Codable, Identifiable { struct AIAnalysisResult: Codable, Identifiable {
let id: String let id: String
let type: String? let userId: String?
let summary: String? let summary: String?
let masteryScore: Int?
let strengths: [String]? let strengths: [String]?
let weaknesses: [String]? let weaknesses: [String]?
let suggestions: [String]? let suggestions: [String]?
let score: Double? let nextActions: [String]?
let rawResult: AIAnalysisRawResult?
let createdAt: String? 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 // MARK: - Feedback
struct FeedbackCreateRequest: Codable { struct FeedbackCreateRequest: Codable {
let type: String let category: String
let content: String let content: String
let contact: String? let email: String?
init(type: String = "general", content: String, contact: String? = nil) { init(category: String = "general", content: String, email: String? = nil) {
self.type = type self.category = category
self.content = content 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 { struct FeedbackData: Codable, Identifiable {
let id: String let id: String?
let type: String? let category: String?
let content: String? let content: String?
let status: String? let status: String?
let createdAt: String? let createdAt: String?
} }
// MARK: - Learning Session // MARK: - Notifications (matches Prisma Notification model)
struct LearningSessionCreateRequest: Codable { struct NotificationItem: Codable, Identifiable {
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 {
let id: String let id: String
let startedAt: String? let userId: String?
let endedAt: String? let type: String
let durationMinutes: Double? let title: String
} let content: String?
let data: [String: String]?
// MARK: - Activity let readAt: String?
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 createdAt: String? let createdAt: String?
} }
struct FocusItemListResponse: Codable { // MARK: - Generic
let success: Bool
let data: [FocusItem]?
}
// MARK: - Generic API Response
struct APIStatusResponse: Codable {
let status: String?
let success: Bool?
}
struct GenericSuccessResponse: Codable { struct GenericSuccessResponse: Codable {
let success: Bool let success: Bool?
let message: String? let message: String?
} }

View File

@ -28,6 +28,16 @@ actor APIClient {
method: String = "GET", method: String = "GET",
body: Encodable? = nil, body: Encodable? = nil,
queryItems: [URLQueryItem]? = 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 { ) async throws -> T {
var components = URLComponents(url: APIConfig.url(path), resolvingAgainstBaseURL: true)! var components = URLComponents(url: APIConfig.url(path), resolvingAgainstBaseURL: true)!
if let queryItems { components.queryItems = queryItems } if let queryItems { components.queryItems = queryItems }
@ -54,10 +64,18 @@ actor APIClient {
switch httpResponse.statusCode { switch httpResponse.statusCode {
case 200, 201: case 200, 201:
do { do {
return try JSONDecoder().decode(T.self, from: data) let envelope = try JSONDecoder().decode(APIEnvelope<T>.self, from: data)
return envelope.data
} catch { } catch {
throw APIError.decodingFailed(error.localizedDescription) 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: case 401:
throw APIError.unauthorized throw APIError.unauthorized
case 400..<500: case 400..<500:
@ -67,6 +85,30 @@ actor APIClient {
throw APIError.requestFailed(httpResponse.statusCode) 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 // MARK: - Helper for encoding arbitrary Encodable

View 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)
}
}

View File

@ -12,7 +12,7 @@ class WaitlistService {
private let client = APIClient.shared private let client = APIClient.shared
func join(email: String, nickname: String?, devices: [String]?, 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, let body = WaitlistCreateRequest(email: email, nickname: nickname, devices: devices,
interests: interests, painpoint: painpoint, willingBeta: willingBeta) interests: interests, painpoint: painpoint, willingBeta: willingBeta)
return try await client.request("/waitlist", method: "POST", body: body) return try await client.request("/waitlist", method: "POST", body: body)
@ -37,16 +37,7 @@ class AuthService {
? AppleAuthRequest.AppleFullName(givenName: givenName, familyName: familyName) ? AppleAuthRequest.AppleFullName(givenName: givenName, familyName: familyName)
: nil : nil
) )
let resp: AuthResponse = try await client.request("/auth/apple", method: "POST", body: body) return 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)
} }
} }
@ -61,9 +52,20 @@ class UserService {
return try await client.request("/users/me") return try await client.request("/users/me")
} }
func updateProfile(nickname: String?, preferences: UserPreferences?) async throws -> UserProfileResponse { func updateProfile(_ dto: UpdateProfileRequest) async throws -> UserProfileResponse {
let body = UpdateUserRequest(nickname: nickname, preferences: preferences) return try await client.request("/users/me", method: "PATCH", body: dto)
return try await client.request("/users/me", method: "PATCH", body: body) }
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() static let shared = KnowledgeBaseService()
private let client = APIClient.shared private let client = APIClient.shared
func list() async throws -> KnowledgeBaseListResponse { func list(page: Int = 1, limit: Int = 20) async throws -> [KnowledgeBase] {
return try await client.request("/knowledge-bases") 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 { func create(title: String, description: String?) async throws -> KnowledgeBase {
let body = CreateKnowledgeBaseRequest(name: name, description: description, icon: icon) let body = CreateKnowledgeBaseRequest(title: title, description: description)
return try await client.request("/knowledge-bases", method: "POST", body: body) 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)") 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 // MARK: - Knowledge Items
@ -95,18 +109,50 @@ class KnowledgeItemService {
static let shared = KnowledgeItemService() static let shared = KnowledgeItemService()
private let client = APIClient.shared private let client = APIClient.shared
func list(baseId: String) async throws -> KnowledgeItemListResponse { func list(knowledgeBaseId: String) async throws -> [KnowledgeItem] {
return try await client.request("/knowledge-items", queryItems: [URLQueryItem(name: "baseId", value: baseId)]) 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)") return try await client.request("/knowledge-items/\(id)")
} }
func create(baseId: String, title: String, content: String?, tags: [String]?) async throws -> KnowledgeItemListResponse { func create(knowledgeBaseId: String, title: String, content: String?, itemType: String? = nil) async throws -> KnowledgeItem {
let body = CreateKnowledgeItemRequest(title: title, content: content, baseId: baseId, tags: tags) let body = CreateKnowledgeItemRequest(
knowledgeBaseId: knowledgeBaseId,
title: title,
content: content,
itemType: itemType
)
return try await client.request("/knowledge-items", method: "POST", body: body) 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 // MARK: - AI Analysis
@ -116,12 +162,78 @@ class AIAnalysisService {
static let shared = AIAnalysisService() static let shared = AIAnalysisService()
private let client = APIClient.shared private let client = APIClient.shared
func analyze(text: String, type: String = "weakness", context: AIAnalysisRequest.AIAnalysisContext? = nil) async throws -> AIAnalysisResponse { func analyze(questionText: String, knowledgeItemContent: String, userAnswer: String) async throws -> AIAnalysisResult {
let body = AIAnalysisRequest(text: text, type: type, context: context) let body = AIAnalysisRequest(
questionText: questionText,
knowledgeItemContent: knowledgeItemContent,
userAnswer: userAnswer,
text: nil,
type: nil
)
return try await client.request("/ai-analysis", method: "POST", body: body) 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 // MARK: - Activity & Stats
@MainActor @MainActor
@ -129,32 +241,12 @@ class ActivityService {
static let shared = ActivityService() static let shared = ActivityService()
private let client = APIClient.shared private let client = APIClient.shared
func summary() async throws -> ActivitySummaryResponse { func summary() async throws -> ActivitySummary {
return try await client.request("/activity/summary") return try await client.request("/activity/summary")
} }
}
// MARK: - Reviews func heatmap() async throws -> [String: Int] {
return try await client.request("/activity/heatmap")
@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")
} }
} }
@ -165,8 +257,27 @@ class FeedbackService {
static let shared = FeedbackService() static let shared = FeedbackService()
private let client = APIClient.shared private let client = APIClient.shared
func submit(type: String = "general", content: String, contact: String? = nil) async throws -> FeedbackResponse { func submit(category: String = "general", content: String, email: String? = nil) async throws -> FeedbackData {
let body = FeedbackCreateRequest(type: type, content: content, contact: contact) let body = FeedbackCreateRequest(category: category, content: content, email: email)
return try await client.request("/feedback", method: "POST", body: body) 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")
}
}

View 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
}
}

View File

@ -7,7 +7,7 @@ import SwiftUI
struct AIHomeView: View { struct AIHomeView: View {
@State private var text = "" @State private var text = ""
@State private var serverStatus: ServerStatus = .checking @State private var serverStatus: ServerStatus = .checking
@State private var knowledgeCount = 0 @State private var serverMessage = ""
@State private var navigateToChat = false @State private var navigateToChat = false
enum ServerStatus { case checking, online, offline } enum ServerStatus { case checking, online, offline }
@ -32,7 +32,7 @@ struct AIHomeView: View {
: serverStatus == .checking ? Color.zxYellow : serverStatus == .checking ? Color.zxYellow
: Color.zxRed) : Color.zxRed)
.frame(width: 6, height: 6) .frame(width: 6, height: 6)
Text(serverStatus == .online ? "API \(knowledgeCount)" Text(serverStatus == .online ? serverMessage
: serverStatus == .checking ? "检测中…" : serverStatus == .checking ? "检测中…"
: "离线") : "离线")
.font(.system(size: 10, weight: .medium)) .font(.system(size: 10, weight: .medium))
@ -69,13 +69,13 @@ struct AIHomeView: View {
private func checkServer() async { private func checkServer() async {
serverStatus = .checking serverStatus = .checking
do { do {
let resp: KnowledgeBaseListResponse = try await APIClient.shared.request("/knowledge-bases") struct HealthResponse: Decodable { let status: String }
let count = resp.count let resp: HealthResponse = try await APIClient.shared.request("/")
knowledgeCount = count
serverStatus = .online serverStatus = .online
serverMessage = resp.status
} catch { } catch {
serverStatus = .offline serverStatus = .offline
print("[API] 服务器检测失败: \(error.localizedDescription)") serverMessage = ""
} }
} }

View File

@ -1,6 +1,7 @@
import SwiftUI import SwiftUI
struct ActiveRecallView: View { struct ActiveRecallView: View {
@StateObject private var viewModel = ActiveRecallViewModel()
let questions: [RecallQuestion] = [ let questions: [RecallQuestion] = [
.init(id: "1", question: "请解释贝叶斯定理的核心思想,并写出公式", source: "机器学习 · 概率论", isVoice: false), .init(id: "1", question: "请解释贝叶斯定理的核心思想,并写出公式", source: "机器学习 · 概率论", isVoice: false),
.init(id: "2", question: "请用自己的话解释梯度下降算法的工作原理", source: "机器学习 · 优化算法", isVoice: false), .init(id: "2", question: "请用自己的话解释梯度下降算法的工作原理", source: "机器学习 · 优化算法", isVoice: false),
@ -38,6 +39,7 @@ struct ActiveRecallView: View {
} }
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar) .toolbarBackground(.hidden, for: .navigationBar)
.task { await viewModel.loadQuestions() }
} }
private var isSubmitted: Bool { submitted.contains(current.id) } private var isSubmitted: Bool { submitted.contains(current.id) }

View File

@ -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)
}
}

View File

@ -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 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){ 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) ZXWeakRow(score:32,topic:"贝叶斯定理应用",lib:"机器学习",priority:"")
NavigationLink(destination: KnowledgeDetailPage()) { ZXWeakRow(score:41,topic:"正态分布性质",lib:"高等数学",priority:"") }.foregroundColor(.primary) ZXWeakRow(score:41,topic:"正态分布性质",lib:"高等数学",priority:"")
ZXWeakRow(score:55,topic:"词根 spect- 相关词汇",lib:"英语词汇",priority:"") ZXWeakRow(score:55,topic:"词根 spect- 相关词汇",lib:"英语词汇",priority:"")
ZXWeakRow(score:48,topic:"协方差与相关系数",lib:"机器学习",priority:"") ZXWeakRow(score:48,topic:"协方差与相关系数",lib:"机器学习",priority:"")
ZXWeakRow(score:36,topic:"梯度下降优化",lib:"机器学习",priority:"") ZXWeakRow(score:36,topic:"梯度下降优化",lib:"机器学习",priority:"")

View File

@ -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
}
}

View File

@ -1,6 +1,7 @@
import SwiftUI import SwiftUI
struct AnalysisHomeView: View { struct AnalysisHomeView: View {
@StateObject private var viewModel = ActivityViewModel()
var body: some View { var body: some View {
ZStack { ZStack {
Color.zxBg0.ignoresSafeArea() Color.zxBg0.ignoresSafeArea()
@ -15,25 +16,29 @@ struct AnalysisHomeView: View {
ScrollView { ScrollView {
VStack(spacing: 16) { VStack(spacing: 16) {
HStack(spacing: 12) { HStack(spacing: 12) {
ZXStatBadge(icon: "trophy.fill", label: "综合掌握", value: "65%", trend: "+8%", color: Color.zxPurple) ZXStatBadge(icon: "trophy.fill", label: "综合掌握", value: "\(viewModel.summary?.dailyAverage ?? 0)%", trend: "", color: Color.zxPurple)
ZXStatBadge(icon: "bolt.fill", label: "本周积分", value: "1,240", trend: "+320", color: Color.zxOrange) ZXStatBadge(icon: "bolt.fill", label: "总分钟", value: "\(viewModel.summary?.totalMinutes ?? 0)", trend: "", color: Color.zxOrange)
ZXStatBadge(icon: "exclamationmark.triangle.fill", label: "待巩固", value: "23", trend: "-5", color: Color.zxYellow) ZXStatBadge(icon: "exclamationmark.triangle.fill", label: "复习卡片", value: "\(viewModel.summary?.totalCardsReviewed ?? 0)", trend: "", color: Color.zxYellow)
ZXStatBadge(icon: "chart.line.uptrend.xyaxis", label: "连续天", value: "14", trend: "🔥", color: Color.zxGreen) ZXStatBadge(icon: "chart.line.uptrend.xyaxis", label: "活跃天", value: "\(viewModel.summary?.activeDays ?? 0)", trend: "", color: Color.zxGreen)
} }
VStack(alignment: .leading, spacing: 16) { 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) } HStack { Text("掌握度趋势").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0); Spacer(); Text("↑ +8% 本周").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxGreen) }
ZXChartView() ZXChartView()
}.padding(16).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20)) }.padding(16).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20))
VStack(alignment: .leading, spacing: 12) { 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) } } 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) } }
NavigationLink(destination: KnowledgeDetailPage()) { ZXWeakRow(score: 32, topic: "贝叶斯定理应用", lib: "机器学习", priority: "") }.foregroundColor(.primary) ForEach(viewModel.focusItems.prefix(5)) { item in
NavigationLink(destination: KnowledgeDetailPage()) { ZXWeakRow(score: 41, topic: "正态分布性质", lib: "高等数学", priority: "") }.foregroundColor(.primary) ZXWeakRow(score: item.masteryScore ?? 0, topic: item.title, lib: item.knowledgeBaseId ?? "", priority: item.priority ?? "normal")
ZXWeakRow(score: 55, topic: "词根 spect- 相关词汇", lib: "英语词汇", priority: "") }
if viewModel.focusItems.isEmpty && !viewModel.isLoading {
Text("暂无薄弱知识点").font(.system(size: 13)).foregroundColor(Color.zxF03)
}
} }
}.padding(.horizontal, 20).padding(.bottom, 120) }.padding(.horizontal, 20).padding(.bottom, 120)
}.scrollIndicators(.hidden) }.scrollIndicators(.hidden)
} }
} }
.task { await viewModel.loadAll() }
} }
} }

View File

@ -5,6 +5,7 @@
import SwiftUI import SwiftUI
struct LibraryHomeView: View { struct LibraryHomeView: View {
@StateObject private var viewModel = LibraryViewModel()
@State private var s = "" @State private var s = ""
var body: some View { var body: some View {
ZStack { ZXGradient.page.ignoresSafeArea() 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) } 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) .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) { ScrollView { VStack(spacing: 12) {
NavigationLink(destination: LibraryDetailPage()) { ZLibraryCard(emoji: "🤖", name: "机器学习", desc: "ML基础 · 深度学习 · 实战项目", color: Color.zxPurple, items: 47, mastery: 72, tags: ["算法","数学","实战"], last: "今天") } ForEach(viewModel.knowledgeBases) { kb in
NavigationLink(destination: LibraryDetailPage()) { ZLibraryCard(emoji: "📐", name: "高等数学", desc: "微积分 · 线代 · 概率论", color: Color.zxOrange, items: 93, mastery: 58, tags: ["公式","定理","习题"], last: "昨天") } NavigationLink(destination: LibraryDetailPage(knowledgeBaseId: kb.id)) {
NavigationLink(destination: LibraryDetailPage()) { ZLibraryCard(emoji: "📖", name: "英语词汇", desc: "GRE · 托福 · 商务英语", color: Color.zxTeal, items: 312, mastery: 84, tags: ["词根","语境","拼写"], last: "3天前") } ZLibraryCard(emoji: "📚", name: kb.title, desc: kb.description ?? "", color: Color.zxPurple, items: kb.itemCount ?? 0, mastery: 50, tags: [], last: lastStudiedText(kb.lastStudiedAt))
NavigationLink(destination: LibraryDetailPage()) { ZLibraryCard(emoji: "🎨", name: "产品设计", desc: "UX 方法论 · 用研 · 交互规范", color: Color.zxYellow, items: 28, mastery: 43, tags: ["方法论","案例"], last: "1周前") } }
}
if viewModel.knowledgeBases.isEmpty && !viewModel.isLoading {
Text("还没有知识库,点击右上角 + 创建").font(.system(size: 13)).foregroundColor(Color.zxF03).padding(.top, 40)
}
NavigationLink(destination: CreateLibraryPage()) { NavigationLink(destination: CreateLibraryPage()) {
HStack(spacing: 8) { Image(systemName: "plus").font(.system(size: 16)); Text("创建新知识库").font(.system(size: 14, weight: .semibold)) } 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) .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) }.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 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

View File

@ -7,27 +7,37 @@ struct CreateLibraryPage: View {
ScrollView { VStack(spacing: 20) { 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: $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)) } 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) } }.padding(.horizontal, 20).padding(.top, 20) }.scrollIndicators(.hidden) }
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)} }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)}
} }
struct LibraryDetailPage: View { struct LibraryDetailPage: View {
let knowledgeBaseId: String
@StateObject private var viewModel = LibraryDetailViewModel()
var body: some View { var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) { ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
HStack { Spacer() HStack { Spacer()
NavigationLink(destination: AddKnowledgePage()) { NavigationLink(destination: AddKnowledgePage(knowledgeBaseId: knowledgeBaseId)) {
Image(systemName: "plus").font(.system(size: 18)).foregroundColor(.white) Image(systemName: "plus").font(.system(size: 18)).foregroundColor(.white)
.frame(width: 36, height: 36).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: 10)) .frame(width: 36, height: 36).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: 10))
} }
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 8) }.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 8)
ScrollView { VStack(spacing: 12) { ScrollView { VStack(spacing: 12) {
NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "偏差-方差权衡", desc: "模型复杂度 · 泛化误差", status: "已掌握", c: Color.zxGreen) } ForEach(viewModel.items) { item in
NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "梯度下降优化", desc: "SGD · Adam · 学习率", status: "学习中", c: Color.zxOrange) } NavigationLink(destination: KnowledgeDetailPage(item: item)) {
NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "正则化方法", desc: "L1 · L2 · Dropout", status: "待复习", c: Color.zxYellow) } ZXCardRow(emoji: "📝", title: item.title, desc: item.summary ?? item.content ?? "", status: item.status ?? "active", c: Color.zxGreen)
NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "过拟合与欠拟合", desc: "偏差方差 · 模型选择", status: "已掌握", 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) } }.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 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()) } 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 { struct AddKnowledgePage: View {
let knowledgeBaseId: String
@State private var title = ""; @State private var content = "" @State private var title = ""; @State private var content = ""
var body: some View { var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) { ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
ScrollView { VStack(spacing: 16) { 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); 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)) } 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) } }.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80) }.scrollIndicators(.hidden) }
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)} }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)}
} }
struct KnowledgeDetailPage: View { struct KnowledgeDetailPage: View {
let item: KnowledgeItem
var body: some View { var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) { ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
HStack { Spacer() HStack { Spacer()
NavigationLink(destination: EditKnowledgePage()) { NavigationLink(destination: EditKnowledgePage(item: item)) {
Image(systemName: "pencil").font(.system(size: 16)).foregroundColor(Color.zxF05) Image(systemName: "pencil").font(.system(size: 16)).foregroundColor(Color.zxF05)
.frame(width: 36, height: 36).background(Color(hex:"#FFFFFF",opacity:0.05)) .frame(width: 36, height: 36).background(Color(hex:"#FFFFFF",opacity:0.05))
.clipShape(RoundedRectangle(cornerRadius: 10)) .clipShape(RoundedRectangle(cornerRadius: 10))
@ -58,7 +72,14 @@ struct KnowledgeDetailPage: View {
} }
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 8) }.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 8)
ScrollView { VStack(spacing: 16) { 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) { HStack(spacing: 12) {
NavigationLink(destination: StudyHomeView()) { 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)) 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 { 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 { var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) { ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
ScrollView { VStack(spacing: 16) { 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); 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)) } 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) } }.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80) }.scrollIndicators(.hidden) }
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)} }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)}
} }

View File

@ -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
}
}
}

View 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)
}
}

View File

@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
struct NotificationListView: View { struct NotificationListView: View {
@State private var notifications: [NotificationItem] = [ @State private var notifications: [ZXNotificationRowData] = [
.init(type: "review", title: "复习提醒", content: "你有 8 个知识点需要复习", time: "刚刚", read: false), .init(type: "review", title: "复习提醒", content: "你有 8 个知识点需要复习", time: "刚刚", read: false),
.init(type: "ai", title: "AI 分析完成", content: "\"机器学习基础\"薄弱点分析已完成", time: "1小时前", read: false), .init(type: "ai", title: "AI 分析完成", content: "\"机器学习基础\"薄弱点分析已完成", time: "1小时前", read: false),
.init(type: "streak", title: "学习成就", content: "恭喜!你已连续学习 14 天 🔥", time: "昨天", read: true), .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 id = UUID()
let type: String let type: String
let title: String let title: String
@ -48,7 +48,7 @@ struct NotificationItem: Identifiable {
} }
struct ZXNotificationRow: View { struct ZXNotificationRow: View {
let item: NotificationItem let item: ZXNotificationRowData
let onTap: () -> Void let onTap: () -> Void
private var iconName: String { private var iconName: String {

View File

@ -1,7 +1,10 @@
import SwiftUI import SwiftUI
struct ProfileView: View { struct ProfileView: View {
@StateObject private var viewModel = ProfileViewModel()
var body: some View { var body: some View {
let _ = Task { if viewModel.userProfile == nil { await viewModel.loadAll() } }
ZStack { ZStack {
ZXGradient.page.ignoresSafeArea() ZXGradient.page.ignoresSafeArea()
ScrollView { ScrollView {
@ -46,14 +49,18 @@ struct ProfileView: View {
} }
} }
private var profileCard: some View { private var profileCard: some View {
NavigationLink(destination: SettingsView()) { let profile = viewModel.userProfile
return NavigationLink(destination: EditProfilePage()) {
VStack(spacing: 16) { VStack(spacing: 16) {
HStack { HStack {
ZStack { Circle().frame(width: 80, height: 80).foregroundColor(Color.zxPurpleBG(0.2)); Text("🧑‍🎓").font(.system(size: 36)) } 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) 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)) }.padding(20).background(ZXGradient.profileCard).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "#7C6EFA", opacity: 0.2), lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20))
}.foregroundColor(.primary) }.foregroundColor(.primary)
} }

View File

@ -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
}
}
}

View File

@ -1,26 +1,38 @@
import SwiftUI import SwiftUI
struct SettingsView: View { struct SettingsView: View {
@State private var language = "zh-Hans" @EnvironmentObject var authManager: AuthManager
@AppStorage("appAppearance") private var appearance = "system" @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 reviewReminder = true
@State private var reminderTime = "20:00" @State private var reminderTime = "20:00"
@State private var intervalDays = "1" @State private var intervalDays = "1"
@State private var iCloudSync = false @State private var iCloudSync = false
@State private var autoBackup = false @State private var autoBackup = false
@State private var showLogoutAlert = false
var body: some View { var body: some View {
ZStack { ZStack {
Color.zxBg0.ignoresSafeArea() Color.zxBg0.ignoresSafeArea()
ScrollView { 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) { VStack(spacing: 16) {
sectionHeader("外观与语言") sectionHeader("外观与语言")
VStack(spacing: 0) { VStack(spacing: 0) {
ZXSettingRow(title: "外观", value: appearanceLabel, icon: "moon.stars.fill", color: Color.zxPurple) ZXSettingRow(title: "外观", value: appearanceLabel, icon: "moon.stars.fill", color: Color.zxPurple)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { toggleAppearance() } .onTapGesture { cycleAppearance() }
ZXSettingDivider() 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)) .background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)) .overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
@ -72,6 +84,28 @@ struct SettingsView: View {
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20)) .background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)) .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) { HStack(spacing: 4) {
Text("知习 v1.0").font(.system(size: 12)).foregroundColor(Color.zxF03) Text("知习 v1.0").font(.system(size: 12)).foregroundColor(Color.zxF03)
}.padding(.bottom, 100) }.padding(.bottom, 100)
@ -90,12 +124,29 @@ struct SettingsView: View {
switch appearance { case "system": return "跟随系统"; case "dark": return "深色模式"; default: return "浅色模式" } switch appearance { case "system": return "跟随系统"; case "dark": return "深色模式"; default: return "浅色模式" }
} }
private func toggleAppearance() { private func cycleAppearance() {
switch appearance { switch appearance {
case "system": appearance = "dark" case "system": appearance = "dark"
case "dark": appearance = "light" case "dark": appearance = "light"
default: appearance = "system" 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) 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) } } 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)) 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) }.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80)
@ -139,6 +196,7 @@ struct GoalSettingDetailView: View {
struct MethodPreferenceView: View { struct MethodPreferenceView: View {
@State private var methods: Set<String> = ["间隔回忆", "费曼技巧"] @State private var methods: Set<String> = ["间隔回忆", "费曼技巧"]
let allMethods = ["间隔回忆", "费曼技巧", "AI 分析", "主动回忆"] let allMethods = ["间隔回忆", "费曼技巧", "AI 分析", "主动回忆"]
@State private var saved = false
var body: some View { var body: some View {
ZStack { ZStack {
@ -157,8 +215,17 @@ struct MethodPreferenceView: View {
}.foregroundColor(.primary) }.foregroundColor(.primary)
} }
} }
Button {} label: { Button {
Text("保存").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) 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) }.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80)
}.scrollIndicators(.hidden) }.scrollIndicators(.hidden)
@ -169,6 +236,7 @@ struct MethodPreferenceView: View {
struct FeedbackFormView: View { struct FeedbackFormView: View {
@State private var type = "功能建议" @State private var type = "功能建议"
@State private var content = "" @State private var content = ""
@State private var submitted = false
let types = ["Bug 反馈", "功能建议", "内容问题", "其他"] let types = ["Bug 反馈", "功能建议", "内容问题", "其他"]
var body: some View { var body: some View {
@ -184,8 +252,13 @@ struct FeedbackFormView: View {
Text("内容").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035) 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)) 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: { Button {
Text("提交").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) 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) }.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80)
}.scrollIndicators(.hidden) }.scrollIndicators(.hidden)

View File

@ -1,6 +1,7 @@
import SwiftUI import SwiftUI
struct ReviewCardView: View { struct ReviewCardView: View {
@StateObject private var viewModel = ReviewViewModel()
let cards: [ReviewCardItem] = [ let cards: [ReviewCardItem] = [
.init(question: "什么是偏差(Bias)和方差(Variance)的权衡?", .init(question: "什么是偏差(Bias)和方差(Variance)的权衡?",
answer: "偏差衡量模型预测与真实值的偏离程度,方差衡量模型在不同训练集上的预测波动。偏差-方差权衡指的是:简单模型偏差高方差低(欠拟合),复杂模型偏差低方差高(过拟合)。最佳模型需要在两者之间取得平衡。", answer: "偏差衡量模型预测与真实值的偏离程度,方差衡量模型在不同训练集上的预测波动。偏差-方差权衡指的是:简单模型偏差高方差低(欠拟合),复杂模型偏差低方差高(过拟合)。最佳模型需要在两者之间取得平衡。",
@ -39,6 +40,7 @@ struct ReviewCardView: View {
} }
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar) .toolbarBackground(.hidden, for: .navigationBar)
.task { await viewModel.loadDueCards() }
} }
private var progressBar: some View { private var progressBar: some View {

View 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 = "提交复习结果失败"
}
}
}

View File

@ -1,6 +1,8 @@
import SwiftUI import SwiftUI
struct StudyHomeView: View { struct StudyHomeView: View {
@StateObject private var studyVM = StudyViewModel()
@StateObject private var reviewVM = ReviewViewModel()
@State private var ts: [ZXSTask] = [ @State private var ts: [ZXSTask] = [
.init(t: "机器学习 - 回忆测试", tp: "回忆测试", c: Color.zxPurple, m: 10, d: true), .init(t: "机器学习 - 回忆测试", tp: "回忆测试", c: Color.zxPurple, m: 10, d: true),
.init(t: "高数 - 间隔复习 8 题", tp: "间隔复习", c: Color.zxOrange, m: 15, d: true), .init(t: "高数 - 间隔复习 8 题", tp: "间隔复习", c: Color.zxOrange, m: 15, d: true),
@ -39,6 +41,7 @@ struct StudyHomeView: View {
.padding(.bottom, 120) } .padding(.bottom, 120) }
.padding(.horizontal, 20) } .padding(.horizontal, 20) }
.scrollIndicators(.hidden) } .scrollIndicators(.hidden) }
.task { await studyVM.loadSessions() }
} }
private var pc: some View { let dn = ts.filter(\.d).count; let pct = CGFloat(dn) / 5 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() 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()

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