Compare commits
3 Commits
dc4ad424e2
...
89d89e542c
| Author | SHA1 | Date | |
|---|---|---|---|
| 89d89e542c | |||
| b182203464 | |||
| 5e19bd740e |
10
AIStudyApp/AIStudyApp.entitlements
Normal file
10
AIStudyApp/AIStudyApp.entitlements
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.developer.applesignin</key>
|
||||||
|
<array>
|
||||||
|
<string>Default</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@ -247,6 +247,7 @@
|
|||||||
buildSettings = {
|
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;
|
||||||
|
|||||||
@ -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,252 @@ 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)
|
||||||
|
.zxToast()
|
||||||
|
.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 +271,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)
|
||||||
|
} } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,14 +13,16 @@ struct ContentView: View {
|
|||||||
default: NavigationStack { AIHomeView() }
|
default: NavigationStack { AIHomeView() }
|
||||||
}
|
}
|
||||||
VStack { Spacer(); ZXTabBar(active: $selectedTab) }.ignoresSafeArea(edges: .bottom)
|
VStack { Spacer(); ZXTabBar(active: $selectedTab) }.ignoresSafeArea(edges: .bottom)
|
||||||
}.ignoresSafeArea(edges: .bottom)
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: selectedTab)
|
||||||
|
.ignoresSafeArea(edges: .bottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ZXTabBar: View {
|
struct ZXTabBar: View {
|
||||||
@Binding var active: String
|
@Binding var active: String
|
||||||
private let items = [("ai","AI","brain.head.profile"),("library","知识库","books.vertical.fill"),("study","学习","bolt.fill"),("analysis","分析","chart.bar.fill"),("profile","我的","person.fill")]
|
private let items = [("ai","AI","brain.head.profile"),("library","知识库","books.vertical.fill"),("study","学习","bolt.fill"),("analysis","分析","chart.bar.fill"),("profile","我的","person.fill")]
|
||||||
var body: some View{HStack(spacing:0){ForEach(items,id:\.0){item in let on=item.0==active;Button{active=item.0}label:{VStack(spacing:4){ZStack{if on{Circle().fill(Color.zxPurple.opacity(0.2)).frame(width:28,height:28).scaleEffect(1.4)};Image(systemName:item.2).font(.system(size:22,weight:on ? .semibold:.regular)).foregroundColor(on ? Color.zxPurple:Color.zxF03)};Text(item.1).font(.system(size:10,weight:on ? .semibold:.regular)).foregroundColor(on ? Color.zxPurple:Color.zxF03)}}.frame(maxWidth:.infinity)}}.padding(.top,6).padding(.bottom,34).frame(height:83).background(.ultraThinMaterial).background(Color.zxBg0.opacity(0.95)).overlay(alignment:.top){Rectangle().fill(Color.zxBorder008).frame(height:1)}}
|
var body: some View{HStack(spacing:0){ForEach(items,id:\.0){item in let on=item.0==active;Button{active=item.0}label:{VStack(spacing:4){ZStack{if on{Circle().fill(Color.zxPurple.opacity(0.2)).frame(width:28,height:28).scaleEffect(1.4)};Image(systemName:item.2).font(.system(size:22,weight:on ? .semibold:.regular)).foregroundColor(on ? Color.zxPurple:Color.zxF03)};Text(item.1).font(.system(size:10,weight:on ? .semibold:.regular)).foregroundColor(on ? Color.zxPurple:Color.zxF03)}}.frame(maxWidth:.infinity)}.accessibilityLabel("\(item.1)标签")}.padding(.top,6).padding(.bottom,34).frame(height:83).background(.ultraThinMaterial).background(Color.zxBg0.opacity(0.95)).overlay(alignment:.top){Rectangle().fill(Color.zxBorder008).frame(height:1)}}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ZXIconBtn: View {
|
struct ZXIconBtn: View {
|
||||||
@ -56,5 +58,5 @@ struct ZXWeakRow: View {
|
|||||||
|
|
||||||
struct ZXAIInputBar: View {
|
struct ZXAIInputBar: View {
|
||||||
@Binding var text:String;let onSend:()->Void
|
@Binding var text:String;let onSend:()->Void
|
||||||
var body: some View {HStack(spacing:10){Image(systemName:"sparkles").font(.system(size:16)).foregroundColor(Color.zxPurple);TextField("问 AI 任何学习问题…",text:$text).font(.system(size:14)).tint(Color.zxPurple);Spacer();Image(systemName:"mic.fill").font(.system(size:18)).foregroundColor(Color.zxF03);Button(action:onSend){Image(systemName:"arrow.up").font(.system(size:14,weight:.bold)).foregroundColor(.white).frame(width:30,height:30).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius:9))}}.padding(.horizontal,14).padding(.vertical,10).background(.ultraThinMaterial).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius:20).stroke(Color.zxBorder008,lineWidth:1)).clipShape(RoundedRectangle(cornerRadius:20)).padding(.horizontal,20).padding(.bottom,34)}
|
var body: some View {HStack(spacing:10){Image(systemName:"sparkles").font(.system(size:16)).foregroundColor(Color.zxPurple);TextField("问 AI 任何学习问题…",text:$text).font(.system(size:14)).tint(Color.zxPurple).accessibilityLabel("AI 学习问题输入框");Spacer();Image(systemName:"mic.fill").font(.system(size:18)).foregroundColor(Color.zxF03).accessibilityLabel("语音输入");Button(action:onSend){Image(systemName:"arrow.up").font(.system(size:14,weight:.bold)).foregroundColor(.white).frame(width:30,height:30).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius:9))}.zxPressable().disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty).accessibilityLabel("发送消息")}.padding(.horizontal,14).padding(.vertical,10).background(.ultraThinMaterial).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius:20).stroke(Color.zxBorder008,lineWidth:1)).clipShape(RoundedRectangle(cornerRadius:20)).padding(.horizontal,20).padding(.bottom,34)}
|
||||||
}
|
}
|
||||||
|
|||||||
103
AIStudyApp/AIStudyApp/Core/Auth/AuthManager.swift
Normal file
103
AIStudyApp/AIStudyApp/Core/Auth/AuthManager.swift
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let tokenExpired = Notification.Name("cloud.longde.AIStudyApp.tokenExpired")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AuthManager: ObservableObject {
|
||||||
|
@Published var isAuthenticated = false
|
||||||
|
@Published var isRestoring = true
|
||||||
|
|
||||||
|
static let shared = AuthManager()
|
||||||
|
|
||||||
|
private var tokenExpiredObserver: NSObjectProtocol?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
tokenExpiredObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: .tokenExpired, object: nil, queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.handleUnauthorized()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let observer = tokenExpiredObserver {
|
||||||
|
NotificationCenter.default.removeObserver(observer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreSession() async {
|
||||||
|
isRestoring = true
|
||||||
|
defer { isRestoring = false }
|
||||||
|
|
||||||
|
guard let token = KeychainHelper.getAccessToken() else {
|
||||||
|
isAuthenticated = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await APIClient.shared.setToken(token)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let _: UserProfileResponse = try await APIClient.shared.request("/users/me")
|
||||||
|
isAuthenticated = true
|
||||||
|
} catch {
|
||||||
|
if let refreshed = await tryRefresh() {
|
||||||
|
await APIClient.shared.setToken(refreshed.accessToken)
|
||||||
|
KeychainHelper.save(
|
||||||
|
accessToken: refreshed.accessToken,
|
||||||
|
refreshToken: refreshed.refreshToken,
|
||||||
|
userId: refreshed.user?.id ?? ""
|
||||||
|
)
|
||||||
|
isAuthenticated = true
|
||||||
|
} else {
|
||||||
|
await APIClient.shared.setToken(nil)
|
||||||
|
KeychainHelper.clear()
|
||||||
|
isAuthenticated = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signIn(_ response: AuthResponse) async {
|
||||||
|
await APIClient.shared.setToken(response.accessToken)
|
||||||
|
KeychainHelper.save(
|
||||||
|
accessToken: response.accessToken,
|
||||||
|
refreshToken: response.refreshToken,
|
||||||
|
userId: response.user?.id ?? ""
|
||||||
|
)
|
||||||
|
isAuthenticated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func signOut() async {
|
||||||
|
if let refreshToken = KeychainHelper.getRefreshToken() {
|
||||||
|
let body = RefreshRequest(refreshToken: refreshToken)
|
||||||
|
let _: GenericSuccessResponse? = try? await APIClient.shared.request(
|
||||||
|
"/auth/logout", method: "POST", body: body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await APIClient.shared.setToken(nil)
|
||||||
|
KeychainHelper.clear()
|
||||||
|
isAuthenticated = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleUnauthorized() {
|
||||||
|
Task {
|
||||||
|
await APIClient.shared.setToken(nil)
|
||||||
|
KeychainHelper.clear()
|
||||||
|
isAuthenticated = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tryRefresh() async -> AuthResponse? {
|
||||||
|
guard let refreshToken = KeychainHelper.getRefreshToken() else { return nil }
|
||||||
|
do {
|
||||||
|
let body = RefreshRequest(refreshToken: refreshToken)
|
||||||
|
return try await APIClient.shared.request("/auth/refresh", method: "POST", body: body)
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
307
AIStudyApp/AIStudyApp/Core/DesignSystem/ZXAnimations.swift
Normal file
307
AIStudyApp/AIStudyApp/Core/DesignSystem/ZXAnimations.swift
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Animated Button Style
|
||||||
|
|
||||||
|
struct ZXButtonStyle: ButtonStyle {
|
||||||
|
let branded: Bool
|
||||||
|
|
||||||
|
init(branded: Bool = false) {
|
||||||
|
self.branded = branded
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.scaleEffect(configuration.isPressed ? 0.96 : 1.0)
|
||||||
|
.opacity(configuration.isPressed ? 0.85 : 1.0)
|
||||||
|
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
|
||||||
|
.sensoryFeedback(.impact(weight: .light), trigger: configuration.isPressed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Scale press modifier (quick apply)
|
||||||
|
|
||||||
|
struct ZXPressModifier: ViewModifier {
|
||||||
|
@State private var pressed = false
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.scaleEffect(pressed ? 0.96 : 1.0)
|
||||||
|
.opacity(pressed ? 0.8 : 1.0)
|
||||||
|
.animation(.easeOut(duration: 0.12), value: pressed)
|
||||||
|
.simultaneousGesture(
|
||||||
|
DragGesture(minimumDistance: 0)
|
||||||
|
.onChanged { _ in pressed = true }
|
||||||
|
.onEnded { _ in pressed = false }
|
||||||
|
)
|
||||||
|
.sensoryFeedback(.impact(weight: .light), trigger: pressed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func zxPressable() -> some View {
|
||||||
|
modifier(ZXPressModifier())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Page transition modifier
|
||||||
|
|
||||||
|
struct ZXPageTransition: ViewModifier {
|
||||||
|
let edge: Edge
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.transition(
|
||||||
|
.move(edge: edge)
|
||||||
|
.combined(with: .opacity)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func zxPageTransition(from edge: Edge = .trailing) -> some View {
|
||||||
|
modifier(ZXPageTransition(edge: edge))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AI Thinking overlay
|
||||||
|
|
||||||
|
struct ZXThinkingOverlay: View {
|
||||||
|
let message: String
|
||||||
|
|
||||||
|
init(_ message: String = "AI 正在分析你的回答…") {
|
||||||
|
self.message = message
|
||||||
|
}
|
||||||
|
|
||||||
|
@State private var show = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black.opacity(0.4).ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Animated brain
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(RadialGradient(
|
||||||
|
colors: [Color(hex: "#7C6EFA", opacity: 0.3), .clear],
|
||||||
|
center: .center, startRadius: 8, endRadius: 32
|
||||||
|
))
|
||||||
|
.frame(width: 64, height: 64)
|
||||||
|
.scaleEffect(show ? 1.3 : 0.8)
|
||||||
|
.animation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true), value: show)
|
||||||
|
|
||||||
|
Image(systemName: "brain.head.profile")
|
||||||
|
.font(.system(size: 28))
|
||||||
|
.foregroundColor(.white.opacity(0.9))
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text(message)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
ZXDotLoader(color: .white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(32)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 24)
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onAppear { show = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Celebration / Confetti effect
|
||||||
|
|
||||||
|
struct ZXCelebrationView: View {
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
@State private var particles: [ConfettiParticle] = []
|
||||||
|
@State private var showContent = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black.opacity(0.5).ignoresSafeArea()
|
||||||
|
.onTapGesture { dismiss() }
|
||||||
|
|
||||||
|
// Particles
|
||||||
|
ForEach(particles) { p in
|
||||||
|
Circle()
|
||||||
|
.fill(p.color)
|
||||||
|
.frame(width: p.size, height: p.size)
|
||||||
|
.position(x: p.x, y: p.y)
|
||||||
|
.opacity(p.opacity)
|
||||||
|
.scaleEffect(p.scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content card
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color(hex: "#7C6EFA"), Color(hex: "#F97316")],
|
||||||
|
startPoint: .topLeading, endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.system(size: 36))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
.scaleEffect(showContent ? 1 : 0.5)
|
||||||
|
.animation(.spring(response: 0.5, dampingFraction: 0.6).delay(0.2), value: showContent)
|
||||||
|
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 22, weight: .heavy))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(Color(hex: "#F0F0FF", opacity: 0.6))
|
||||||
|
}
|
||||||
|
.opacity(showContent ? 1 : 0)
|
||||||
|
.offset(y: showContent ? 0 : 20)
|
||||||
|
|
||||||
|
Button(action: dismiss) {
|
||||||
|
Text("继续学习")
|
||||||
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 52)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color(hex: "#7C6EFA"), Color(hex: "#F97316")],
|
||||||
|
startPoint: .topLeading, endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
}
|
||||||
|
.opacity(showContent ? 1 : 0)
|
||||||
|
}
|
||||||
|
.padding(28)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 24)
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
showContent = true
|
||||||
|
launchConfetti()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dismiss() {
|
||||||
|
withAnimation(.easeOut(duration: 0.25)) { showContent = false }
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func launchConfetti() {
|
||||||
|
let colors: [Color] = [Color(hex: "#7C6EFA"), Color(hex: "#F97316"),
|
||||||
|
Color(hex: "#A78BFA"), Color(hex: "#34D399"),
|
||||||
|
Color(hex: "#F59E0B"), Color(hex: "#4ECDC4")]
|
||||||
|
var ps: [ConfettiParticle] = []
|
||||||
|
for i in 0..<60 {
|
||||||
|
let delay = Double(i) * 0.015
|
||||||
|
let x = CGFloat.random(in: 0...UIScreen.main.bounds.width)
|
||||||
|
let endY = CGFloat.random(in: 80...UIScreen.main.bounds.height * 0.7)
|
||||||
|
let size = CGFloat.random(in: 4...10)
|
||||||
|
let color = colors.randomElement()!
|
||||||
|
ps.append(ConfettiParticle(
|
||||||
|
id: UUID(), color: color, size: size,
|
||||||
|
x: x, y: -30, targetY: endY,
|
||||||
|
scale: 1, opacity: 1, delay: delay
|
||||||
|
))
|
||||||
|
}
|
||||||
|
particles = ps
|
||||||
|
|
||||||
|
for p in particles {
|
||||||
|
withAnimation(.spring(response: 0.8, dampingFraction: 0.6).delay(p.delay)) {
|
||||||
|
if let idx = particles.firstIndex(where: { $0.id == p.id }) {
|
||||||
|
particles[idx].y = p.targetY
|
||||||
|
particles[idx].opacity = 0.4
|
||||||
|
particles[idx].scale = 0.3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ConfettiParticle: Identifiable {
|
||||||
|
let id: UUID
|
||||||
|
let color: Color
|
||||||
|
let size: CGFloat
|
||||||
|
var x: CGFloat
|
||||||
|
var y: CGFloat
|
||||||
|
let targetY: CGFloat
|
||||||
|
var scale: CGFloat
|
||||||
|
var opacity: Double
|
||||||
|
let delay: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AI Analysis progress view
|
||||||
|
|
||||||
|
struct ZXAIAnalysisProgress: View {
|
||||||
|
let steps: [String]
|
||||||
|
@State private var currentStep = 0
|
||||||
|
@State private var progress: CGFloat = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
ZStack {
|
||||||
|
ZXLoadingView(size: 48, lineWidth: 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("AI 分析中…")
|
||||||
|
.font(.system(size: 17, weight: .bold))
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
Text(steps[safe: currentStep] ?? steps.last ?? "")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
|
||||||
|
GeometryReader { g in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 3)
|
||||||
|
.fill(Color.zxFill008)
|
||||||
|
.frame(height: 6)
|
||||||
|
RoundedRectangle(cornerRadius: 3)
|
||||||
|
.fill(ZXGradient.progressBar)
|
||||||
|
.frame(width: g.size.width * progress, height: 6)
|
||||||
|
.animation(.easeInOut(duration: 0.6), value: progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 6)
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
}
|
||||||
|
.padding(28)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 20)
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
.onAppear {
|
||||||
|
var delay: TimeInterval = 0.8
|
||||||
|
for i in 0..<steps.count {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||||
|
currentStep = i
|
||||||
|
progress = CGFloat(i + 1) / CGFloat(steps.count)
|
||||||
|
}
|
||||||
|
delay += 1.2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Array {
|
||||||
|
subscript(safe index: Int) -> Element? {
|
||||||
|
indices.contains(index) ? self[index] : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
135
AIStudyApp/AIStudyApp/Core/DesignSystem/ZXLoadingView.swift
Normal file
135
AIStudyApp/AIStudyApp/Core/DesignSystem/ZXLoadingView.swift
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Branded loading spinner
|
||||||
|
|
||||||
|
struct ZXLoadingView: View {
|
||||||
|
let size: CGFloat
|
||||||
|
let lineWidth: CGFloat
|
||||||
|
|
||||||
|
init(size: CGFloat = 36, lineWidth: CGFloat = 3) {
|
||||||
|
self.size = size
|
||||||
|
self.lineWidth = lineWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
@State private var rotation: Double = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// rotating gradient arc
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0.05, to: 0.8)
|
||||||
|
.stroke(
|
||||||
|
AngularGradient(
|
||||||
|
colors: [Color(hex: "#7C6EFA"), Color(hex: "#A78BFA"), Color(hex: "#F97316"), Color(hex: "#7C6EFA")],
|
||||||
|
center: .center,
|
||||||
|
startAngle: .degrees(rotation),
|
||||||
|
endAngle: .degrees(rotation + 300)
|
||||||
|
),
|
||||||
|
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||||
|
)
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.rotationEffect(.degrees(rotation))
|
||||||
|
|
||||||
|
// center dot
|
||||||
|
Circle()
|
||||||
|
.fill(Color(hex: "#7C6EFA", opacity: 0.3))
|
||||||
|
.frame(width: size * 0.3, height: size * 0.3)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) {
|
||||||
|
rotation = 360
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Full-screen loading overlay
|
||||||
|
|
||||||
|
struct ZXLoadingOverlay: View {
|
||||||
|
let message: String?
|
||||||
|
|
||||||
|
init(_ message: String? = nil) {
|
||||||
|
self.message = message
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black.opacity(0.35).ignoresSafeArea()
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ZXLoadingView(size: 44, lineWidth: 3.5)
|
||||||
|
if let message {
|
||||||
|
Text(message)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(28)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 20)
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Skeleton shimmer (for placeholder loading)
|
||||||
|
|
||||||
|
struct ZXShimmer: ViewModifier {
|
||||||
|
@State private var phase: CGFloat = -0.5
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.overlay(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color.white.opacity(0),
|
||||||
|
Color.white.opacity(0.06),
|
||||||
|
Color.white.opacity(0),
|
||||||
|
],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
.rotationEffect(.degrees(15))
|
||||||
|
.scaleEffect(2)
|
||||||
|
.offset(x: phase * 400)
|
||||||
|
.animation(.linear(duration: 1.5).repeatForever(autoreverses: false), value: phase)
|
||||||
|
)
|
||||||
|
.clipped()
|
||||||
|
.onAppear { phase = 1.5 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func zxShimmer() -> some View {
|
||||||
|
modifier(ZXShimmer())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Staggered dot loader (for inline use, e.g. AI thinking)
|
||||||
|
|
||||||
|
struct ZXDotLoader: View {
|
||||||
|
@State private var step = 0
|
||||||
|
let color: Color
|
||||||
|
|
||||||
|
init(color: Color = Color.zxPurple) {
|
||||||
|
self.color = color
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(0..<3, id: \.self) { i in
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
.scaleEffect(step == i ? 1.2 : 0.7)
|
||||||
|
.opacity(step == i ? 1 : 0.4)
|
||||||
|
.animation(.easeInOut(duration: 0.4), value: step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { timer in
|
||||||
|
step = (step + 1) % 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Refreshable ScrollView with load-more
|
||||||
|
|
||||||
|
struct ZXRefreshableScrollView<Content: View>: View {
|
||||||
|
let onRefresh: () async -> Void
|
||||||
|
let onLoadMore: (() async -> Void)?
|
||||||
|
let hasMore: Bool
|
||||||
|
let content: () -> Content
|
||||||
|
|
||||||
|
init(
|
||||||
|
onRefresh: @escaping () async -> Void,
|
||||||
|
onLoadMore: (() async -> Void)? = nil,
|
||||||
|
hasMore: Bool = false,
|
||||||
|
@ViewBuilder content: @escaping () -> Content
|
||||||
|
) {
|
||||||
|
self.onRefresh = onRefresh
|
||||||
|
self.onLoadMore = onLoadMore
|
||||||
|
self.hasMore = hasMore
|
||||||
|
self.content = content
|
||||||
|
}
|
||||||
|
|
||||||
|
@State private var isRefreshing = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
// Pull-to-refresh anchor
|
||||||
|
if isRefreshing {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ZXLoadingView(size: 28, lineWidth: 2.5)
|
||||||
|
Text("刷新中…")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
content()
|
||||||
|
|
||||||
|
// Load-more footer
|
||||||
|
if let onLoadMore, hasMore {
|
||||||
|
ZXLoadMoreFooter(action: onLoadMore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
.refreshable {
|
||||||
|
await onRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Load-more footer
|
||||||
|
|
||||||
|
struct ZXLoadMoreFooter: View {
|
||||||
|
let action: () async -> Void
|
||||||
|
|
||||||
|
@State private var isLoading = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
if isLoading {
|
||||||
|
ZXLoadingView(size: 20, lineWidth: 2)
|
||||||
|
Text("加载中…")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
} else {
|
||||||
|
Text("上拉加载更多")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
.padding(.bottom, 80)
|
||||||
|
.task {
|
||||||
|
isLoading = true
|
||||||
|
await action()
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pull-to-refresh modifier (for plain ScrollView)
|
||||||
|
|
||||||
|
struct ZXPullToRefreshModifier: ViewModifier {
|
||||||
|
let onRefresh: () async -> Void
|
||||||
|
@State private var isRefreshing = false
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if isRefreshing {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ZXLoadingView(size: 22, lineWidth: 2)
|
||||||
|
Text("正在刷新…")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
|
}
|
||||||
|
content
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.25), value: isRefreshing)
|
||||||
|
.refreshable {
|
||||||
|
isRefreshing = true
|
||||||
|
await onRefresh()
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func zxPullToRefresh(_ action: @escaping () async -> Void) -> some View {
|
||||||
|
modifier(ZXPullToRefreshModifier(onRefresh: action))
|
||||||
|
}
|
||||||
|
}
|
||||||
153
AIStudyApp/AIStudyApp/Core/DesignSystem/ZXToast.swift
Normal file
153
AIStudyApp/AIStudyApp/Core/DesignSystem/ZXToast.swift
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Toast type
|
||||||
|
|
||||||
|
enum ZXToastType {
|
||||||
|
case success, error, warning, info
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .success: return "checkmark.circle.fill"
|
||||||
|
case .error: return "xmark.circle.fill"
|
||||||
|
case .warning: return "exclamationmark.triangle.fill"
|
||||||
|
case .info: return "info.circle.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var color: Color {
|
||||||
|
switch self {
|
||||||
|
case .success: return Color.zxGreen
|
||||||
|
case .error: return Color.zxRed
|
||||||
|
case .warning: return Color.zxOrange
|
||||||
|
case .info: return Color.zxPurple
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Global toast manager
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ZXToastManager: ObservableObject {
|
||||||
|
static let shared = ZXToastManager()
|
||||||
|
|
||||||
|
@Published var current: ZXToastItem?
|
||||||
|
|
||||||
|
private var queue: [ZXToastItem] = []
|
||||||
|
private var hideTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func show(_ message: String, type: ZXToastType = .info, duration: TimeInterval = 2.5) {
|
||||||
|
let item = ZXToastItem(message: message, type: type)
|
||||||
|
if current != nil {
|
||||||
|
queue.append(item)
|
||||||
|
current = nil
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
|
||||||
|
self?.showNext()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
|
||||||
|
current = item
|
||||||
|
}
|
||||||
|
scheduleHide(duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func success(_ message: String) { show(message, type: .success) }
|
||||||
|
func error(_ message: String) { show(message, type: .error) }
|
||||||
|
func warning(_ message: String) { show(message, type: .warning) }
|
||||||
|
func info(_ message: String) { show(message, type: .info) }
|
||||||
|
|
||||||
|
private func showNext() {
|
||||||
|
guard !queue.isEmpty else { return }
|
||||||
|
let next = queue.removeFirst()
|
||||||
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
scheduleHide(next.duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleHide(_ duration: TimeInterval) {
|
||||||
|
hideTask?.cancel()
|
||||||
|
hideTask = Task {
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await MainActor.run {
|
||||||
|
withAnimation(.easeOut(duration: 0.25)) {
|
||||||
|
current = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||||
|
showNext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ZXToastItem: Equatable {
|
||||||
|
let id = UUID()
|
||||||
|
let message: String
|
||||||
|
let type: ZXToastType
|
||||||
|
let duration: TimeInterval
|
||||||
|
|
||||||
|
init(message: String, type: ZXToastType, duration: TimeInterval = 2.5) {
|
||||||
|
self.message = message
|
||||||
|
self.type = type
|
||||||
|
self.duration = duration
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: ZXToastItem, rhs: ZXToastItem) -> Bool { lhs.id == rhs.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Toast overlay modifier
|
||||||
|
|
||||||
|
struct ZXToastOverlay: ViewModifier {
|
||||||
|
@ObservedObject private var manager = ZXToastManager.shared
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content.overlay(alignment: .top) {
|
||||||
|
if let item = manager.current {
|
||||||
|
ZXToastBar(item: item)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, ZXSpacing.statusBarH + 8)
|
||||||
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func zxToast() -> some View {
|
||||||
|
modifier(ZXToastOverlay())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Toast bar view
|
||||||
|
|
||||||
|
struct ZXToastBar: View {
|
||||||
|
let item: ZXToastItem
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: item.type.icon)
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundColor(item.type.color)
|
||||||
|
Text(item.message)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
.lineLimit(2)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.stroke(item.type.color.opacity(0.2), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.shadow(color: Color.black.opacity(0.25), radius: 12, y: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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?
|
||||||
|
|
||||||
struct UserPreferences: Codable {
|
|
||||||
let dailyGoal: Int?
|
|
||||||
let reminderTime: String?
|
|
||||||
let theme: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct UserStats: Codable {
|
|
||||||
let totalLearningDays: Int?
|
|
||||||
let completedCourses: Int?
|
|
||||||
let totalMinutes: Int?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct UpdateUserRequest: Codable {
|
|
||||||
let nickname: String?
|
|
||||||
let preferences: UserPreferences?
|
let preferences: UserPreferences?
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Knowledge Base
|
struct UserProfileData: Codable {
|
||||||
|
let learningIdentity: String?
|
||||||
|
let learningDirection: String?
|
||||||
|
let bio: String?
|
||||||
|
let currentGoal: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserPreferences: Codable, Equatable {
|
||||||
|
let preferredMethods: [String]?
|
||||||
|
let defaultFocusMinutes: Int?
|
||||||
|
let aiSuggestionLevel: String?
|
||||||
|
let language: String?
|
||||||
|
let appearance: String?
|
||||||
|
let notificationEnabled: Bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdateProfileRequest: Codable {
|
||||||
|
let nickname: String?
|
||||||
|
let avatarUrl: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
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?
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 }
|
||||||
@ -53,11 +63,14 @@ actor APIClient {
|
|||||||
|
|
||||||
switch httpResponse.statusCode {
|
switch httpResponse.statusCode {
|
||||||
case 200, 201:
|
case 200, 201:
|
||||||
do {
|
return try decodeResponse(data)
|
||||||
return try JSONDecoder().decode(T.self, from: data)
|
case 401 where !isRetry:
|
||||||
} catch {
|
if let newToken = await refreshAccessToken() {
|
||||||
throw APIError.decodingFailed(error.localizedDescription)
|
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 +80,49 @@ 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: - Decoding
|
||||||
|
|
||||||
|
private func decodeResponse<T: Decodable>(_ data: Data) throws -> T {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
// Try unwrapped response first (no envelope), then wrapped
|
||||||
|
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
json["data"] == nil && json["success"] == nil {
|
||||||
|
return try decoder.decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
// Has envelope wrapper
|
||||||
|
do {
|
||||||
|
let envelope = try decoder.decode(APIEnvelope<T>.self, from: data)
|
||||||
|
return envelope.data
|
||||||
|
} catch {
|
||||||
|
// Fallback: try decoding T directly (e.g. server returns unwrapped on some endpoints)
|
||||||
|
return try decoder.decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper for encoding arbitrary Encodable
|
// MARK: - Helper for encoding arbitrary Encodable
|
||||||
|
|||||||
43
AIStudyApp/AIStudyApp/Core/Security/FileCache.swift
Normal file
43
AIStudyApp/AIStudyApp/Core/Security/FileCache.swift
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class FileCache {
|
||||||
|
private let directory: URL
|
||||||
|
private let encoder = JSONEncoder()
|
||||||
|
private let decoder = JSONDecoder()
|
||||||
|
|
||||||
|
init(suite: String) {
|
||||||
|
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
|
||||||
|
directory = base.appendingPathComponent("FileCache/\(suite)", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func url(forKey key: String) -> URL {
|
||||||
|
directory.appendingPathComponent("\(key).json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func save<T: Encodable>(_ value: T, forKey key: String) throws {
|
||||||
|
let data = try encoder.encode(value)
|
||||||
|
try data.write(to: url(forKey: key), options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
func load<T: Decodable>(_ type: T.Type, forKey key: String) throws -> T? {
|
||||||
|
let fileURL = url(forKey: key)
|
||||||
|
guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil }
|
||||||
|
let data = try Data(contentsOf: fileURL)
|
||||||
|
return try decoder.decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(forKey key: String) throws {
|
||||||
|
let fileURL = url(forKey: key)
|
||||||
|
if FileManager.default.fileExists(atPath: fileURL.path) {
|
||||||
|
try FileManager.default.removeItem(at: fileURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() throws {
|
||||||
|
if FileManager.default.fileExists(atPath: directory.path) {
|
||||||
|
try FileManager.default.removeItem(at: directory)
|
||||||
|
}
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
62
AIStudyApp/AIStudyApp/Core/Security/KeychainHelper.swift
Normal file
62
AIStudyApp/AIStudyApp/Core/Security/KeychainHelper.swift
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
enum KeychainHelper {
|
||||||
|
private static let service = "cloud.longde.AIStudyApp"
|
||||||
|
private static let accessTokenKey = "accessToken"
|
||||||
|
private static let refreshTokenKey = "refreshToken"
|
||||||
|
private static let userIdKey = "userId"
|
||||||
|
|
||||||
|
static func save(accessToken: String, refreshToken: String?, userId: String) {
|
||||||
|
save(key: accessTokenKey, value: accessToken)
|
||||||
|
if let rt = refreshToken { save(key: refreshTokenKey, value: rt) }
|
||||||
|
save(key: userIdKey, value: userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func getAccessToken() -> String? { get(key: accessTokenKey) }
|
||||||
|
static func getRefreshToken() -> String? { get(key: refreshTokenKey) }
|
||||||
|
static func getUserId() -> String? { get(key: userIdKey) }
|
||||||
|
|
||||||
|
static func clear() {
|
||||||
|
delete(key: accessTokenKey)
|
||||||
|
delete(key: refreshTokenKey)
|
||||||
|
delete(key: userIdKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private static func save(key: String, value: String) {
|
||||||
|
let data = Data(value.utf8)
|
||||||
|
delete(key: key)
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecValueData as String: data,
|
||||||
|
]
|
||||||
|
SecItemAdd(query as CFDictionary, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func get(key: String) -> String? {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecReturnData as String: true,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||||
|
]
|
||||||
|
var item: CFTypeRef?
|
||||||
|
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
|
||||||
|
let data = item as? Data else { return nil }
|
||||||
|
return String(data: data, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func delete(key: String) {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
]
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
92
AIStudyApp/AIStudyApp/Core/Security/LocalCache.swift
Normal file
92
AIStudyApp/AIStudyApp/Core/Security/LocalCache.swift
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Lightweight offline cache wrapper: memory → disk → network fallback.
|
||||||
|
/// Uses UserDefaults for small values, FileCache for larger blobs.
|
||||||
|
@MainActor
|
||||||
|
final class LocalCache {
|
||||||
|
static let shared = LocalCache()
|
||||||
|
|
||||||
|
private let defaults = UserDefaults.standard
|
||||||
|
private let fileCache = FileCache(suite: "local_cache")
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Simple values (UserDefaults)
|
||||||
|
|
||||||
|
func get<T>(_ key: String) -> T? where T: Decodable {
|
||||||
|
// Try memory/disk via FileCache first, then UserDefaults
|
||||||
|
if let cached: T = try? fileCache.load(T.self, forKey: key) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func set<T>(_ value: T, forKey key: String) where T: Encodable {
|
||||||
|
try? fileCache.save(value, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(_ key: String) {
|
||||||
|
try? fileCache.remove(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Array caching (common pattern)
|
||||||
|
|
||||||
|
func getList<T: Decodable>(_ key: String) -> [T] {
|
||||||
|
(try? fileCache.load([T].self, forKey: key)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func setList<T: Encodable>(_ items: [T], forKey key: String) {
|
||||||
|
try? fileCache.save(items, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Expiry-based caching
|
||||||
|
|
||||||
|
func getWithExpiry<T: Decodable>(_ key: String, ttl: TimeInterval = 300) -> T? {
|
||||||
|
let expiryKey = "\(key)_expiry"
|
||||||
|
let expiry = defaults.double(forKey: expiryKey)
|
||||||
|
guard expiry == 0 || Date().timeIntervalSince1970 < expiry else {
|
||||||
|
remove(key)
|
||||||
|
defaults.removeObject(forKey: expiryKey)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setWithExpiry<T: Encodable>(_ value: T, forKey key: String, ttl: TimeInterval = 300) {
|
||||||
|
set(value, forKey: key)
|
||||||
|
defaults.set(Date().timeIntervalSince1970 + ttl, forKey: "\(key)_expiry")
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAll() {
|
||||||
|
try? fileCache.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ViewModel caching helper
|
||||||
|
|
||||||
|
extension LocalCache {
|
||||||
|
/// Wrap an API fetch with cache-first strategy.
|
||||||
|
/// Returns cached data instantly, then refreshes in background.
|
||||||
|
func cacheFirst<T: Codable>(
|
||||||
|
key: String,
|
||||||
|
ttl: TimeInterval = 300,
|
||||||
|
fetch: @Sendable () async throws -> T
|
||||||
|
) async throws -> T {
|
||||||
|
if let cached: T = getWithExpiry(key, ttl: ttl) {
|
||||||
|
Task { try? await refreshCache(key: key, ttl: ttl, fetch: fetch) }
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
let fresh = try await fetch()
|
||||||
|
setWithExpiry(fresh, forKey: key, ttl: ttl)
|
||||||
|
return fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshCache<T: Codable>(
|
||||||
|
key: String,
|
||||||
|
ttl: TimeInterval,
|
||||||
|
fetch: @Sendable () async throws -> T
|
||||||
|
) async throws {
|
||||||
|
let fresh = try await fetch()
|
||||||
|
setWithExpiry(fresh, forKey: key, ttl: ttl)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
24
AIStudyApp/AIStudyApp/Features/AI/AIAnalysisViewModel.swift
Normal file
24
AIStudyApp/AIStudyApp/Features/AI/AIAnalysisViewModel.swift
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class AIAnalysisViewModel: ObservableObject {
|
||||||
|
@Published var analysisResult: AIAnalysisResult?
|
||||||
|
@Published var isAnalyzing = false
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
|
||||||
|
func requestAnalysis(questionText: String, knowledgeItemContent: String, userAnswer: String) async {
|
||||||
|
isAnalyzing = true
|
||||||
|
errorMessage = nil
|
||||||
|
do {
|
||||||
|
analysisResult = try await AIAnalysisService.shared.analyze(
|
||||||
|
questionText: questionText,
|
||||||
|
knowledgeItemContent: knowledgeItemContent,
|
||||||
|
userAnswer: userAnswer
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
errorMessage = "AI 分析失败"
|
||||||
|
}
|
||||||
|
isAnalyzing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
42
AIStudyApp/AIStudyApp/Features/AI/AIChatViewModel.swift
Normal file
42
AIStudyApp/AIStudyApp/Features/AI/AIChatViewModel.swift
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct AIMessage: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let role: AIMessageRole
|
||||||
|
let content: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AIMessageRole {
|
||||||
|
case user, ai
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AIChatViewModel: ObservableObject {
|
||||||
|
@Published var messages: [AIMessage] = [
|
||||||
|
AIMessage(role: .ai, content: "你好!我是你的 AI 学习助手。")
|
||||||
|
]
|
||||||
|
@Published var inputText = ""
|
||||||
|
@Published var isSending = false
|
||||||
|
|
||||||
|
var canSend: Bool {
|
||||||
|
!inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isSending
|
||||||
|
}
|
||||||
|
|
||||||
|
func send() {
|
||||||
|
guard canSend else { return }
|
||||||
|
let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
messages.append(AIMessage(role: .user, content: text))
|
||||||
|
inputText = ""
|
||||||
|
isSending = true
|
||||||
|
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||||||
|
messages.append(AIMessage(
|
||||||
|
role: .ai,
|
||||||
|
content: "好的,我理解你的问题。需要我帮你制定学习计划吗?"
|
||||||
|
))
|
||||||
|
isSending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,6 +96,8 @@ struct AIHomeView: View {
|
|||||||
.frame(maxWidth:.infinity).frame(height:42)
|
.frame(maxWidth:.infinity).frame(height:42)
|
||||||
.background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius:12))
|
.background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius:12))
|
||||||
}
|
}
|
||||||
|
.accessibilityLabel("开始回答今日思考题")
|
||||||
|
.accessibilityHint("用费曼方法解释注意力机制")
|
||||||
}
|
}
|
||||||
.padding(16).background(ZXGradient.thinkingCard)
|
.padding(16).background(ZXGradient.thinkingCard)
|
||||||
.overlay(RoundedRectangle(cornerRadius:20).stroke(Color(hex:"#7C6EFA",opacity:0.1),lineWidth:1))
|
.overlay(RoundedRectangle(cornerRadius:20).stroke(Color(hex:"#7C6EFA",opacity:0.1),lineWidth:1))
|
||||||
@ -165,6 +167,7 @@ struct AIHomeView: View {
|
|||||||
Image(systemName:"arrow.up").font(.system(size:14,weight:.bold)).foregroundColor(.white)
|
Image(systemName:"arrow.up").font(.system(size:14,weight:.bold)).foregroundColor(.white)
|
||||||
.frame(width:30,height:30).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius:9))
|
.frame(width:30,height:30).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius:9))
|
||||||
}
|
}
|
||||||
|
.accessibilityLabel("发送消息,开始 AI 对话")
|
||||||
}
|
}
|
||||||
.padding(.horizontal,14).padding(.vertical,10)
|
.padding(.horizontal,14).padding(.vertical,10)
|
||||||
.background(.ultraThinMaterial).background(Color.zxFill004)
|
.background(.ultraThinMaterial).background(Color.zxFill004)
|
||||||
@ -187,6 +190,7 @@ struct ZXQuickAction: View {
|
|||||||
}
|
}
|
||||||
.frame(width:72,height:72)
|
.frame(width:72,height:72)
|
||||||
.background(Color.zxFill005).clipShape(RoundedRectangle(cornerRadius:16))
|
.background(Color.zxFill005).clipShape(RoundedRectangle(cornerRadius:16))
|
||||||
|
.accessibilityLabel(label.replacingOccurrences(of: "\n", with: ""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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),
|
||||||
@ -12,6 +13,8 @@ struct ActiveRecallView: View {
|
|||||||
@State private var currentAnswer = ""
|
@State private var currentAnswer = ""
|
||||||
@State private var submitted: Set<String> = []
|
@State private var submitted: Set<String> = []
|
||||||
@State private var showFinish = false
|
@State private var showFinish = false
|
||||||
|
@State private var showThinking = false
|
||||||
|
@State private var showCelebration = false
|
||||||
|
|
||||||
var current: RecallQuestion { questions[idx] }
|
var current: RecallQuestion { questions[idx] }
|
||||||
|
|
||||||
@ -38,6 +41,19 @@ struct ActiveRecallView: View {
|
|||||||
}
|
}
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbarBackground(.hidden, for: .navigationBar)
|
.toolbarBackground(.hidden, for: .navigationBar)
|
||||||
|
.task { await viewModel.loadQuestions() }
|
||||||
|
.overlay {
|
||||||
|
if showThinking {
|
||||||
|
ZXThinkingOverlay("AI 正在分析你的回答…")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if showCelebration {
|
||||||
|
ZXCelebrationView(title: "回忆完成", subtitle: "你已完成所有主动回忆题目,AI 分析结果已生成") {
|
||||||
|
withAnimation(.easeOut(duration: 0.3)) { showCelebration = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isSubmitted: Bool { submitted.contains(current.id) }
|
private var isSubmitted: Bool { submitted.contains(current.id) }
|
||||||
@ -89,6 +105,9 @@ struct ActiveRecallView: View {
|
|||||||
.background(ZXGradient.thinkingCard)
|
.background(ZXGradient.thinkingCard)
|
||||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1))
|
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel("问题 \(idx + 1):\(current.question)")
|
||||||
|
.accessibilityHint(current.isVoice ? "语音题,双击录音回答" : "文字题,在下方输入回答")
|
||||||
}
|
}
|
||||||
|
|
||||||
private var answerInput: some View {
|
private var answerInput: some View {
|
||||||
@ -114,6 +133,13 @@ struct ActiveRecallView: View {
|
|||||||
answers[current.id] = current.isVoice ? "语音答案已录制" : currentAnswer
|
answers[current.id] = current.isVoice ? "语音答案已录制" : currentAnswer
|
||||||
submitted.insert(current.id)
|
submitted.insert(current.id)
|
||||||
currentAnswer = ""
|
currentAnswer = ""
|
||||||
|
if submitted.count == questions.count {
|
||||||
|
showThinking = true
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||||
|
showThinking = false
|
||||||
|
showCelebration = true
|
||||||
|
}
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text("提交回答")
|
Text("提交回答")
|
||||||
.font(.system(size: 14, weight: .bold))
|
.font(.system(size: 14, weight: .bold))
|
||||||
@ -122,8 +148,11 @@ struct ActiveRecallView: View {
|
|||||||
.background(ZXGradient.ctaPurple)
|
.background(ZXGradient.ctaPurple)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
}
|
}
|
||||||
|
.zxPressable()
|
||||||
.disabled(currentAnswer.isEmpty && !current.isVoice)
|
.disabled(currentAnswer.isEmpty && !current.isVoice)
|
||||||
.opacity(currentAnswer.isEmpty && !current.isVoice ? 0.5 : 1)
|
.opacity(currentAnswer.isEmpty && !current.isVoice ? 0.5 : 1)
|
||||||
|
.accessibilityLabel("提交回答")
|
||||||
|
.accessibilityHint("提交后可由 AI 分析你的回答质量")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -166,8 +195,11 @@ struct ActiveRecallView: View {
|
|||||||
.background(ZXGradient.brand)
|
.background(ZXGradient.brand)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
}
|
}
|
||||||
|
.zxPressable()
|
||||||
} else {
|
} else {
|
||||||
NavigationLink(destination: AIFeedbackPageView()) {
|
Button {
|
||||||
|
showCelebration = true
|
||||||
|
} label: {
|
||||||
Label("查看 AI 分析结果", systemImage: "sparkles")
|
Label("查看 AI 分析结果", systemImage: "sparkles")
|
||||||
.font(.system(size: 14, weight: .bold))
|
.font(.system(size: 14, weight: .bold))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
@ -175,6 +207,7 @@ struct ActiveRecallView: View {
|
|||||||
.background(ZXGradient.ctaPurple)
|
.background(ZXGradient.ctaPurple)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
}
|
}
|
||||||
|
.zxPressable()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,37 +10,186 @@ struct DailyThinkingPage: View {
|
|||||||
Text("AI会从三个方面评估你的回答:核心概念理解 · 理论深度 · 实际应用能力").font(.system(size:12)).foregroundColor(Color.zxF04)
|
Text("AI会从三个方面评估你的回答:核心概念理解 · 理论深度 · 实际应用能力").font(.system(size:12)).foregroundColor(Color.zxF04)
|
||||||
}.padding(16).background(ZXGradient.thinkingCard).clipShape(RoundedRectangle(cornerRadius:16))
|
}.padding(16).background(ZXGradient.thinkingCard).clipShape(RoundedRectangle(cornerRadius:16))
|
||||||
VStack(alignment:.leading,spacing:8){Text("你的回答").font(.system(size:13,weight:.semibold)).foregroundColor(Color.zxF04);TextEditor(text:$answer).font(.system(size:13)).foregroundColor(Color.zxF0).tint(Color.zxPurple).frame(minHeight:160).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:13,weight:.semibold)).foregroundColor(Color.zxF04);TextEditor(text:$answer).font(.system(size:13)).foregroundColor(Color.zxF0).tint(Color.zxPurple).frame(minHeight:160).scrollContentBackground(.hidden).padding(12).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius:14)).overlay(RoundedRectangle(cornerRadius:14).stroke(Color.zxBorder008,lineWidth:1))}
|
||||||
if !submitted{ NavigationLink(destination:AIFeedbackPageView()){ Text("提交回答,获取 AI 反馈").font(.system(size:14,weight:.bold)).foregroundColor(.white).frame(maxWidth:.infinity).frame(height:52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius:16)).shadow(color:Color(hex:"#7C6EFA",opacity:0.3),radius:24) } }
|
if !submitted{ NavigationLink(destination:AIFeedbackPageView()){ Text("提交回答,获取 AI 反馈").font(.system(size:14,weight:.bold)).foregroundColor(.white).frame(maxWidth:.infinity).frame(height:52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius:16)).shadow(color:Color(hex:"#7C6EFA",opacity:0.3),radius:24) }.zxPressable() }
|
||||||
}.padding(.horizontal,20).padding(.top, 8).padding(.bottom,120) }.scrollIndicators(.hidden)
|
}.padding(.horizontal,20).padding(.top, 8).padding(.bottom,120) }.scrollIndicators(.hidden)
|
||||||
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden,for:.navigationBar)
|
}.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 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:"高")
|
||||||
}.padding(.horizontal,20).padding(.top, 8).padding(.bottom,80)}.scrollIndicators(.hidden)}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden,for:.navigationBar)} }
|
}.padding(.horizontal,20).padding(.top, 8).padding(.bottom,80)}.scrollIndicators(.hidden)}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden,for:.navigationBar)} }
|
||||||
struct AIFeedbackPageView: View {
|
struct AIFeedbackPageView: View {
|
||||||
@State private var navigateToChat = false
|
@State private var navigateToChat = false
|
||||||
|
@State private var isAnalyzing = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack{Color.zxBg0.ignoresSafeArea();ScrollView{VStack(spacing:16){ HStack(spacing:20){ ZStack{Circle().trim(from:0,to:0.78).stroke(ZXGradient.brand,style:StrokeStyle(lineWidth:10,lineCap:.round)).rotationEffect(.degrees(-90)).frame(width:80,height:80);VStack(spacing:0){Text("78").font(.system(size:22,weight:.heavy)).foregroundColor(Color.zxPurple);Text("/ 100").font(.system(size:9)).foregroundColor(Color.zxF04)}};VStack(alignment:.leading,spacing:2){Text("良好掌握").font(.system(size:18,weight:.heavy)).foregroundColor(Color.zxF0);Text("理解核心概念,但缺少理论深度和解决方案").font(.system(size:12)).foregroundColor(Color.zxF0045).lineSpacing(4)};Spacer() }.padding(20).background(ZXGradient.feedbackScore).clipShape(RoundedRectangle(cornerRadius:20)).overlay(RoundedRectangle(cornerRadius:20).stroke(Color(hex:"#7C6EFA",opacity:0.2),lineWidth:1))
|
ZStack {
|
||||||
VStack(alignment:.leading,spacing:8){Text("你的回答").font(.system(size:13,weight:.semibold)).foregroundColor(Color.zxF04);Text("过拟合就像一个学生只会「死记硬背」考题,而不是真正理解知识…").font(.system(size:13)).foregroundColor(Color.zxF007).lineSpacing(6).padding(14).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius:14)).overlay(RoundedRectangle(cornerRadius:14).stroke(Color.zxBorder006,lineWidth:1))}
|
Color.zxBg0.ignoresSafeArea()
|
||||||
VStack(alignment:.leading,spacing:8){HStack(spacing:8){Image(systemName:"checkmark.circle.fill").foregroundColor(Color.zxGreen);Text("答对的部分").font(.system(size:14,weight:.bold)).foregroundColor(Color.zxF0)};ForEach(["正确识别出过拟合是\"记住训练数据\"而非\"学习规律\"","使用了死记硬背类比,方向正确且贴切"],id:\.self){s in HStack(alignment:.top,spacing:12){Circle().fill(Color.zxGreen).frame(width:6,height:6).padding(.top,6);Text(s).font(.system(size:13)).foregroundColor(Color(hex:"#F0F0FF",opacity:0.75)).lineSpacing(4)}.padding(12).background(Color(hex:"#34D399",opacity:0.07)).clipShape(RoundedRectangle(cornerRadius:12)).overlay(RoundedRectangle(cornerRadius:12).stroke(Color(hex:"#34D399",opacity:0.18),lineWidth:1))}}
|
if isAnalyzing {
|
||||||
NavigationLink(destination: StudyHomeView()) {
|
ZXAIAnalysisProgress(steps: [
|
||||||
Label("加入待巩固,安排间隔复习",systemImage:"bolt.fill").font(.system(size:14,weight:.bold)).foregroundColor(.white).frame(maxWidth:.infinity).frame(height:52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius:16)).shadow(color:Color(hex:"#7C6EFA",opacity:0.3),radius:24)
|
"解析你的回答结构…",
|
||||||
}
|
"对比知识库标准答案…",
|
||||||
HStack(spacing:12){
|
"评估概念理解深度…",
|
||||||
NavigationLink(destination: AIChatPage()) {
|
"生成个性化反馈…"
|
||||||
HStack(spacing:4){Text("深入提问").font(.system(size:13));Image(systemName:"chevron.right").font(.system(size:14))}.foregroundColor(Color.zxF05).frame(maxWidth:.infinity).frame(height:44).background(Color.zxFill005).clipShape(RoundedRectangle(cornerRadius:14)).overlay(RoundedRectangle(cornerRadius:14).stroke(Color.zxBorder008,lineWidth:1))
|
])
|
||||||
}
|
.onAppear {
|
||||||
NavigationLink(destination: DailyThinkingPage()) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
|
||||||
HStack(spacing:4){Text("再来一题").font(.system(size:13));Image(systemName:"chevron.right").font(.system(size:14))}.foregroundColor(Color.zxF05).frame(maxWidth:.infinity).frame(height:44).background(Color.zxFill005).clipShape(RoundedRectangle(cornerRadius:14)).overlay(RoundedRectangle(cornerRadius:14).stroke(Color.zxBorder008,lineWidth:1))
|
withAnimation(.easeOut(duration: 0.4)) { isAnalyzing = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
ZStack {
|
||||||
|
Circle().trim(from: 0, to: 0.78).stroke(ZXGradient.brand, style: StrokeStyle(lineWidth: 10, lineCap: .round)).rotationEffect(.degrees(-90)).frame(width: 80, height: 80)
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Text("78").font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxPurple)
|
||||||
|
Text("/ 100").font(.system(size: 9)).foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("良好掌握").font(.system(size: 18, weight: .heavy)).foregroundColor(Color.zxF0)
|
||||||
|
Text("理解核心概念,但缺少理论深度和解决方案").font(.system(size: 12)).foregroundColor(Color.zxF0045).lineSpacing(4)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.background(ZXGradient.feedbackScore)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "#7C6EFA", opacity: 0.2), lineWidth: 1))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("你的回答").font(.system(size: 13, weight: .semibold)).foregroundColor(Color.zxF04)
|
||||||
|
Text("过拟合就像一个学生只会「死记硬背」考题,而不是真正理解知识…").font(.system(size: 13)).foregroundColor(Color.zxF007).lineSpacing(6).padding(14).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder006, lineWidth: 1))
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "checkmark.circle.fill").foregroundColor(Color.zxGreen)
|
||||||
|
Text("答对的部分").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0)
|
||||||
|
}
|
||||||
|
ForEach(["正确识别出过拟合是\"记住训练数据\"而非\"学习规律\"", "使用了死记硬背类比,方向正确且贴切"], id: \.self) { s in
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
Circle().fill(Color.zxGreen).frame(width: 6, height: 6).padding(.top, 6)
|
||||||
|
Text(s).font(.system(size: 13)).foregroundColor(Color(hex: "#F0F0FF", opacity: 0.75)).lineSpacing(4)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color(hex: "#34D399", opacity: 0.07))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Color(hex: "#34D399", opacity: 0.18), lineWidth: 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NavigationLink(destination: StudyHomeView()) {
|
||||||
|
Label("加入待巩固,安排间隔复习", systemImage: "bolt.fill")
|
||||||
|
.font(.system(size: 14, weight: .bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(maxWidth: .infinity).frame(height: 52)
|
||||||
|
.background(ZXGradient.ctaPurple)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.shadow(color: Color(hex: "#7C6EFA", opacity: 0.3), radius: 24)
|
||||||
|
}
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
NavigationLink(destination: AIChatPage()) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text("深入提问").font(.system(size: 13))
|
||||||
|
Image(systemName: "chevron.right").font(.system(size: 14))
|
||||||
|
}
|
||||||
|
.foregroundColor(Color.zxF05)
|
||||||
|
.frame(maxWidth: .infinity).frame(height: 44)
|
||||||
|
.background(Color.zxFill005)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
|
||||||
|
}
|
||||||
|
NavigationLink(destination: DailyThinkingPage()) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text("再来一题").font(.system(size: 13))
|
||||||
|
Image(systemName: "chevron.right").font(.system(size: 14))
|
||||||
|
}
|
||||||
|
.foregroundColor(Color.zxF05)
|
||||||
|
.frame(maxWidth: .infinity).frame(height: 44)
|
||||||
|
.background(Color.zxFill005)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80)
|
||||||
|
}
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.padding(.horizontal,20).padding(.top, 8).padding(.bottom,80)}.scrollIndicators(.hidden)}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden,for:.navigationBar)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbarBackground(.hidden, for: .navigationBar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// MARK: - AI Chat
|
||||||
|
|
||||||
|
struct AIChatPage: View {
|
||||||
|
@StateObject private var vm = AIChatViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.zxBg0.ignoresSafeArea()
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ForEach(vm.messages) { m in
|
||||||
|
chatBubble(m)
|
||||||
|
.id(m.id)
|
||||||
|
}
|
||||||
|
if vm.isSending {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "brain.head.profile").foregroundColor(Color.zxPurple)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.background(Color(hex: "#7C6EFA", opacity: 0.15))
|
||||||
|
.clipShape(Circle())
|
||||||
|
ZXDotLoader(color: Color.zxPurple)
|
||||||
|
.padding(.leading, 4)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
|
||||||
|
}
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
.onChange(of: vm.messages.count) { _ in
|
||||||
|
withAnimation { proxy.scrollTo(vm.messages.last?.id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ZXAIInputBar(text: $vm.inputText, onSend: { vm.send() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbarBackground(.hidden, for: .navigationBar)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func chatBubble(_ m: AIMessage) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
if m.role == .ai {
|
||||||
|
Image(systemName: "brain.head.profile").foregroundColor(Color.zxPurple)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.background(Color(hex: "#7C6EFA", opacity: 0.15))
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
Text(m.content).font(.system(size: 14))
|
||||||
|
.foregroundColor(m.role == .user ? .white : Color.zxF007)
|
||||||
|
.padding(12)
|
||||||
|
.background(m.role == .user ? AnyView(ZXGradient.brandPurple) : AnyView(Color.zxFill004))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
if m.role == .user {
|
||||||
|
Circle().frame(width: 28, height: 28)
|
||||||
|
.foregroundColor(Color.zxPurpleBG(0.2))
|
||||||
|
.overlay(Text("我").font(.system(size: 10, weight: .bold)).foregroundColor(Color.zxPurple))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: m.role == .user ? .trailing : .leading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
struct AIChatPage: View { @State private var msg="";@State private var msgs:[(String,String)]=[("ai","你好!我是你的 AI 学习助手。")]; var body: some View { ZStack{Color.zxBg0.ignoresSafeArea();VStack(spacing:0){ScrollViewReader{proxy in ScrollView{VStack(spacing:16){ForEach(Array(msgs.enumerated()),id:\.offset){i,m in HStack(alignment:.top,spacing:8){if m.0=="ai"{Image(systemName:"brain.head.profile").foregroundColor(Color.zxPurple).frame(width:28,height:28).background(Color(hex:"#7C6EFA",opacity:0.15)).clipShape(Circle())};Text(m.1).font(.system(size:14)).foregroundColor(m.0=="user" ? .white:Color.zxF007).padding(12).background(m.0=="user" ? AnyView(ZXGradient.brandPurple):AnyView(Color.zxFill004)).clipShape(RoundedRectangle(cornerRadius:16));if m.0=="user"{Circle().frame(width:28,height:28).foregroundColor(Color.zxPurpleBG(0.2)).overlay(Text("我").font(.system(size:10,weight:.bold)).foregroundColor(Color.zxPurple))}}.frame(maxWidth:.infinity,alignment:m.0=="user" ? .trailing:.leading)}}.padding(.horizontal,20).padding(.top, 8).padding(.bottom,100).id("bottom")}.scrollIndicators(.hidden).onChange(of:msgs.count){withAnimation{proxy.scrollTo("bottom")}}};ZXAIInputBar(text:$msg,onSend:{guard !msg.isEmpty else{return};msgs.append(("user",msg));msg="";DispatchQueue.main.asyncAfter(deadline:.now()+1){msgs.append(("ai","好的,我理解你的问题。需要我帮你制定学习计划吗?"))}})}}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden,for:.navigationBar)} }
|
|
||||||
|
|||||||
@ -0,0 +1,40 @@
|
|||||||
|
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 loadAll() async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
do {
|
||||||
|
async let s = ActivityService.shared.summary()
|
||||||
|
async let f = FocusItemService.shared.list()
|
||||||
|
async let h = ActivityService.shared.heatmap()
|
||||||
|
let (summaryResult, focusResult, heatmapResult) = try await (s, f, h)
|
||||||
|
summary = summaryResult
|
||||||
|
focusItems = focusResult
|
||||||
|
heatmap = heatmapResult
|
||||||
|
} catch {
|
||||||
|
if summary == nil { errorMessage = "加载分析数据失败" }
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
do {
|
||||||
|
async let s = ActivityService.shared.summary()
|
||||||
|
async let f = FocusItemService.shared.list()
|
||||||
|
async let h = ActivityService.shared.heatmap()
|
||||||
|
let (summaryResult, focusResult, heatmapResult) = try await (s, f, h)
|
||||||
|
summary = summaryResult
|
||||||
|
focusItems = focusResult
|
||||||
|
heatmap = heatmapResult
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
@ -14,26 +15,36 @@ struct AnalysisHomeView: View {
|
|||||||
.padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 12)
|
.padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 12)
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
|
if viewModel.isLoading && viewModel.summary == nil {
|
||||||
|
VStack(spacing: 12) { ZXLoadingView(size: 36, lineWidth: 3); Text("加载中…").font(.system(size: 13)).foregroundColor(Color.zxF04) }
|
||||||
|
.frame(maxWidth: .infinity).padding(.top, 80)
|
||||||
|
}
|
||||||
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) }.accessibilityLabel("查看全部薄弱知识点") }
|
||||||
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)
|
||||||
|
.zxPullToRefresh { await viewModel.refresh() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.task { await viewModel.loadAll() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
@ -16,29 +17,53 @@ struct LibraryHomeView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1))
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1))
|
||||||
}
|
}
|
||||||
|
.accessibilityLabel("搜索知识库")
|
||||||
NavigationLink(destination: ImportPage()) {
|
NavigationLink(destination: ImportPage()) {
|
||||||
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)
|
.frame(width: 36, height: 36).background(ZXGradient.brand)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
}
|
}
|
||||||
|
.accessibilityLabel("导入新知识库")
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 12)
|
.padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 12)
|
||||||
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).accessibilityLabel("搜索知识库") }
|
||||||
.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)
|
||||||
|
.accessibilityHint("输入关键词搜索知识库或知识点")
|
||||||
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: "今天") }
|
if viewModel.isLoading && viewModel.knowledgeBases.isEmpty {
|
||||||
NavigationLink(destination: LibraryDetailPage()) { ZLibraryCard(emoji: "📐", name: "高等数学", desc: "微积分 · 线代 · 概率论", color: Color.zxOrange, items: 93, mastery: 58, tags: ["公式","定理","习题"], last: "昨天") }
|
VStack(spacing: 12) { ZXLoadingView(size: 36, lineWidth: 3); Text("加载中…").font(.system(size: 13)).foregroundColor(Color.zxF04) }
|
||||||
NavigationLink(destination: LibraryDetailPage()) { ZLibraryCard(emoji: "📖", name: "英语词汇", desc: "GRE · 托福 · 商务英语", color: Color.zxTeal, items: 312, mastery: 84, tags: ["词根","语境","拼写"], last: "3天前") }
|
.frame(maxWidth: .infinity).padding(.top, 80)
|
||||||
NavigationLink(destination: LibraryDetailPage()) { ZLibraryCard(emoji: "🎨", name: "产品设计", desc: "UX 方法论 · 用研 · 交互规范", color: Color.zxYellow, items: 28, mastery: 43, tags: ["方法论","案例"], last: "1周前") }
|
}
|
||||||
|
ForEach(viewModel.knowledgeBases) { kb in
|
||||||
|
NavigationLink(destination: LibraryDetailPage(knowledgeBaseId: kb.id)) {
|
||||||
|
ZLibraryCard(emoji: "📚", name: kb.title, desc: kb.description ?? "", color: Color.zxPurple, items: kb.itemCount ?? 0, mastery: 50, tags: [], last: lastStudiedText(kb.lastStudiedAt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if viewModel.knowledgeBases.isEmpty && !viewModel.isLoading {
|
||||||
|
Text("还没有知识库,点击右上角 + 创建").font(.system(size: 13)).foregroundColor(Color.zxF03).padding(.top, 40)
|
||||||
|
}
|
||||||
|
if viewModel.hasMore {
|
||||||
|
ZXLoadMoreFooter { await viewModel.loadMore() }
|
||||||
|
}
|
||||||
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)
|
||||||
.overlay(RoundedRectangle(cornerRadius: 16).strokeBorder(style: StrokeStyle(lineWidth: 1.5, dash: [6, 4]), antialiased: true).foregroundColor(Color.zxBorder01))
|
.overlay(RoundedRectangle(cornerRadius: 16).strokeBorder(style: StrokeStyle(lineWidth: 1.5, dash: [6, 4]), antialiased: true).foregroundColor(Color.zxBorder01))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
}
|
}
|
||||||
}.padding(.horizontal, 20).padding(.bottom, 120) }.scrollIndicators(.hidden)
|
.accessibilityLabel("创建新知识库")
|
||||||
|
.accessibilityHint("导入文档或文本生成结构化知识库")
|
||||||
|
}.padding(.horizontal, 20).padding(.bottom, 120) }
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
.zxPullToRefresh { await viewModel.refresh() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.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
|
||||||
|
|||||||
@ -7,27 +7,46 @@ 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) }
|
if viewModel.isLoading && viewModel.items.isEmpty {
|
||||||
NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "梯度下降优化", desc: "SGD · Adam · 学习率", status: "学习中", c: Color.zxOrange) }
|
VStack(spacing: 12) { ZXLoadingView(size: 36, lineWidth: 3); Text("加载中…").font(.system(size: 13)).foregroundColor(Color.zxF04) }
|
||||||
NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "正则化方法", desc: "L1 · L2 · Dropout", status: "待复习", c: Color.zxYellow) }
|
.frame(maxWidth: .infinity).padding(.top, 80)
|
||||||
NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "过拟合与欠拟合", desc: "偏差方差 · 模型选择", status: "已掌握", c: Color.zxGreen) }
|
}
|
||||||
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden) }
|
ForEach(viewModel.items) { item in
|
||||||
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)}
|
NavigationLink(destination: KnowledgeDetailPage(item: item)) {
|
||||||
|
ZXCardRow(emoji: "📝", title: item.title, desc: item.summary ?? item.content ?? "", status: item.status ?? "active", c: Color.zxGreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if viewModel.items.isEmpty && !viewModel.isLoading {
|
||||||
|
Text("暂无知识点").font(.system(size: 13)).foregroundColor(Color.zxF03).padding(.top, 40)
|
||||||
|
}
|
||||||
|
if viewModel.hasMore {
|
||||||
|
ZXLoadMoreFooter { await viewModel.loadMore(knowledgeBaseId: knowledgeBaseId) }
|
||||||
|
}
|
||||||
|
}.padding(.horizontal, 20).padding(.bottom, 80) }
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
.zxPullToRefresh { await viewModel.refresh(knowledgeBaseId: knowledgeBaseId) } }
|
||||||
|
}.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 +54,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 +81,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 +120,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)}
|
||||||
}
|
}
|
||||||
|
|||||||
145
AIStudyApp/AIStudyApp/Features/Library/LibraryViewModel.swift
Normal file
145
AIStudyApp/AIStudyApp/Features/Library/LibraryViewModel.swift
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class LibraryViewModel: ObservableObject {
|
||||||
|
@Published var knowledgeBases: [KnowledgeBase] = []
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var isRefreshing = false
|
||||||
|
@Published var isLoadingMore = false
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
@Published var hasMore = true
|
||||||
|
|
||||||
|
private var currentPage = 1
|
||||||
|
private let pageSize = 20
|
||||||
|
|
||||||
|
func loadKnowledgeBases() async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
currentPage = 1
|
||||||
|
do {
|
||||||
|
knowledgeBases = try await KnowledgeBaseService.shared.list(page: 1, limit: pageSize)
|
||||||
|
hasMore = knowledgeBases.count >= pageSize
|
||||||
|
} catch {
|
||||||
|
if knowledgeBases.isEmpty { errorMessage = "加载知识库失败" }
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
isRefreshing = true
|
||||||
|
currentPage = 1
|
||||||
|
do {
|
||||||
|
knowledgeBases = try await KnowledgeBaseService.shared.list(page: 1, limit: pageSize)
|
||||||
|
hasMore = knowledgeBases.count >= pageSize
|
||||||
|
} catch {}
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMore() async {
|
||||||
|
guard !isLoadingMore, hasMore else { return }
|
||||||
|
isLoadingMore = true
|
||||||
|
currentPage += 1
|
||||||
|
do {
|
||||||
|
let more = try await KnowledgeBaseService.shared.list(page: currentPage, limit: pageSize)
|
||||||
|
knowledgeBases.append(contentsOf: more)
|
||||||
|
hasMore = more.count >= pageSize
|
||||||
|
} catch {
|
||||||
|
currentPage -= 1
|
||||||
|
}
|
||||||
|
isLoadingMore = 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)
|
||||||
|
ZXToastManager.shared.success("知识库已创建")
|
||||||
|
return kb
|
||||||
|
} catch {
|
||||||
|
ZXToastManager.shared.error("创建失败")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteKnowledgeBase(id: String) async {
|
||||||
|
do {
|
||||||
|
_ = try await KnowledgeBaseService.shared.delete(id: id)
|
||||||
|
knowledgeBases.removeAll { $0.id == id }
|
||||||
|
ZXToastManager.shared.success("已删除")
|
||||||
|
} catch {
|
||||||
|
ZXToastManager.shared.error("删除失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class LibraryDetailViewModel: ObservableObject {
|
||||||
|
@Published var items: [KnowledgeItem] = []
|
||||||
|
@Published var knowledgeBase: KnowledgeBase?
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var isRefreshing = false
|
||||||
|
@Published var isLoadingMore = false
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
@Published var hasMore = true
|
||||||
|
|
||||||
|
private var currentPage = 1
|
||||||
|
private let pageSize = 20
|
||||||
|
|
||||||
|
func loadItems(knowledgeBaseId: String) async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
currentPage = 1
|
||||||
|
do {
|
||||||
|
items = try await KnowledgeItemService.shared.list(knowledgeBaseId: knowledgeBaseId)
|
||||||
|
hasMore = items.count >= pageSize
|
||||||
|
} catch {
|
||||||
|
if items.isEmpty { errorMessage = "加载知识点失败" }
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh(knowledgeBaseId: String) async {
|
||||||
|
isRefreshing = true
|
||||||
|
currentPage = 1
|
||||||
|
do {
|
||||||
|
items = try await KnowledgeItemService.shared.list(knowledgeBaseId: knowledgeBaseId)
|
||||||
|
hasMore = items.count >= pageSize
|
||||||
|
} catch {}
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMore(knowledgeBaseId: String) async {
|
||||||
|
guard !isLoadingMore, hasMore else { return }
|
||||||
|
isLoadingMore = true
|
||||||
|
currentPage += 1
|
||||||
|
do {
|
||||||
|
let more = try await KnowledgeItemService.shared.list(knowledgeBaseId: knowledgeBaseId)
|
||||||
|
items.append(contentsOf: more)
|
||||||
|
hasMore = more.count >= pageSize
|
||||||
|
} catch {
|
||||||
|
currentPage -= 1
|
||||||
|
}
|
||||||
|
isLoadingMore = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadKnowledgeBase(id: String) async {
|
||||||
|
do {
|
||||||
|
knowledgeBase = try await KnowledgeBaseService.shared.detail(id: id)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
ZXToastManager.shared.success("知识点已添加")
|
||||||
|
return item
|
||||||
|
} catch {
|
||||||
|
ZXToastManager.shared.error("添加失败")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
135
AIStudyApp/AIStudyApp/Features/Profile/EditProfilePage.swift
Normal file
135
AIStudyApp/AIStudyApp/Features/Profile/EditProfilePage.swift
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
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
|
||||||
|
@State private var isSaving = false
|
||||||
|
@State private var saveError: String?
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
if let error = saveError {
|
||||||
|
Text(error).font(.system(size: 13)).foregroundColor(.red)
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 10)
|
||||||
|
.background(Color.red.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
isSaving = true
|
||||||
|
saveError = nil
|
||||||
|
do {
|
||||||
|
_ = 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
|
||||||
|
} catch {
|
||||||
|
saveError = "保存失败: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
isSaving = false
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if isSaving { ProgressView().tint(.white) }
|
||||||
|
Text(saved ? "已保存" : "保存修改")
|
||||||
|
.font(.system(size: 14, weight: .bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 52)
|
||||||
|
.background {
|
||||||
|
if isSaving { Color.gray } else { ZXGradient.ctaPurple }
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
}
|
||||||
|
.disabled(isSaving)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80)
|
||||||
|
}
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
}
|
||||||
|
.navigationTitle("编辑资料")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbarBackground(.hidden, for: .navigationBar)
|
||||||
|
.task {
|
||||||
|
await viewModel.loadProfile()
|
||||||
|
nickname = viewModel.userProfile?.nickname ?? ""
|
||||||
|
learningIdentity = viewModel.profileData?.learningIdentity ?? ""
|
||||||
|
learningDirection = viewModel.profileData?.learningDirection ?? ""
|
||||||
|
bio = viewModel.profileData?.bio ?? ""
|
||||||
|
currentGoal = viewModel.profileData?.currentGoal ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sectionHeader(_ text: String) -> some View {
|
||||||
|
Text(text).font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5).padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ZXEditField: View {
|
||||||
|
let title: String
|
||||||
|
@Binding var text: String
|
||||||
|
let placeholder: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0).frame(width: 72, alignment: .leading)
|
||||||
|
TextField(placeholder, text: $text)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.tint(Color.zxPurple)
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 14)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,28 +1,29 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct NotificationListView: View {
|
struct NotificationListView: View {
|
||||||
@State private var notifications: [NotificationItem] = [
|
@State private var notifications: [NotificationItem] = []
|
||||||
.init(type: "review", title: "复习提醒", content: "你有 8 个知识点需要复习", time: "刚刚", read: false),
|
@State private var isLoading = false
|
||||||
.init(type: "ai", title: "AI 分析完成", content: "\"机器学习基础\"薄弱点分析已完成", time: "1小时前", read: false),
|
@State private var isRefreshing = false
|
||||||
.init(type: "streak", title: "学习成就", content: "恭喜!你已连续学习 14 天 🔥", time: "昨天", read: true),
|
|
||||||
.init(type: "review", title: "复习提醒", content: "今天有 3 个知识点需要费曼解释练习", time: "2天前", read: true),
|
|
||||||
.init(type: "system", title: "系统通知", content: "v1.0 版本已更新,新增间隔复习功能", time: "3天前", read: true),
|
|
||||||
]
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.zxBg0.ignoresSafeArea()
|
Color.zxBg0.ignoresSafeArea()
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if notifications.isEmpty {
|
if isLoading && notifications.isEmpty {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ZXLoadingView(size: 36, lineWidth: 3)
|
||||||
|
Text("加载中…").font(.system(size: 13)).foregroundColor(Color.zxF04)
|
||||||
|
}.padding(.top, 120)
|
||||||
|
} else if notifications.isEmpty {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Image(systemName: "bell.slash").font(.system(size: 40)).foregroundColor(Color.zxF03)
|
Image(systemName: "bell.slash").font(.system(size: 40)).foregroundColor(Color.zxF03)
|
||||||
Text("暂无通知").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF03)
|
Text("暂无通知").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF03)
|
||||||
}.padding(.top, 120)
|
}.padding(.top, 120)
|
||||||
} else {
|
} else {
|
||||||
ForEach(Array(notifications.enumerated()), id: \.offset) { i, n in
|
ForEach(Array(notifications.enumerated()), id: \.offset) { i, n in
|
||||||
ZXNotificationRow(item: n) {
|
ZXNotificationItemRow(item: n) {
|
||||||
notifications[i].read = true
|
Task { _ = try? await NotificationService.shared.markRead(id: n.id) }
|
||||||
}
|
}
|
||||||
if i < notifications.count - 1 {
|
if i < notifications.count - 1 {
|
||||||
ZXSettingDivider()
|
ZXSettingDivider()
|
||||||
@ -33,28 +34,40 @@ struct NotificationListView: 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))
|
||||||
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
|
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
|
||||||
}.scrollIndicators(.hidden)
|
}
|
||||||
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)
|
.scrollIndicators(.hidden)
|
||||||
|
.zxPullToRefresh { await refresh() }
|
||||||
|
}
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbarBackground(.hidden, for: .navigationBar)
|
||||||
|
.task { await loadNotifications() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadNotifications() async {
|
||||||
|
isLoading = true
|
||||||
|
do {
|
||||||
|
notifications = try await NotificationService.shared.list()
|
||||||
|
} catch { /* keep empty state */ }
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refresh() async {
|
||||||
|
isRefreshing = true
|
||||||
|
do {
|
||||||
|
notifications = try await NotificationService.shared.list()
|
||||||
|
} catch {}
|
||||||
|
isRefreshing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NotificationItem: Identifiable {
|
struct ZXNotificationItemRow: View {
|
||||||
let id = UUID()
|
|
||||||
let type: String
|
|
||||||
let title: String
|
|
||||||
let content: String
|
|
||||||
let time: String
|
|
||||||
var read: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ZXNotificationRow: View {
|
|
||||||
let item: NotificationItem
|
let item: NotificationItem
|
||||||
let onTap: () -> Void
|
let onTap: () -> Void
|
||||||
|
|
||||||
private var iconName: String {
|
private var iconName: String {
|
||||||
switch item.type {
|
switch item.type {
|
||||||
case "review": return "arrow.triangle.2.circlepath"
|
case "review": return "arrow.triangle.2.circlepath"
|
||||||
case "ai": return "sparkles"
|
case "ai_analysis": return "sparkles"
|
||||||
case "streak": return "flame.fill"
|
case "streak": return "flame.fill"
|
||||||
default: return "bell.fill"
|
default: return "bell.fill"
|
||||||
}
|
}
|
||||||
@ -63,7 +76,7 @@ struct ZXNotificationRow: View {
|
|||||||
private var iconColor: Color {
|
private var iconColor: Color {
|
||||||
switch item.type {
|
switch item.type {
|
||||||
case "review": return Color.zxOrange
|
case "review": return Color.zxOrange
|
||||||
case "ai": return Color.zxPurple
|
case "ai_analysis": return Color.zxPurple
|
||||||
case "streak": return Color.zxGreen
|
case "streak": return Color.zxGreen
|
||||||
default: return Color.zxAccent
|
default: return Color.zxAccent
|
||||||
}
|
}
|
||||||
@ -77,12 +90,14 @@ struct ZXNotificationRow: View {
|
|||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(item.title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0)
|
Text(item.title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0)
|
||||||
if !item.read {
|
if item.readAt == nil {
|
||||||
Circle().fill(Color.zxPurple).frame(width: 6, height: 6)
|
Circle().fill(Color.zxPurple).frame(width: 6, height: 6)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(item.content).font(.system(size: 12)).foregroundColor(Color.zxF04).lineLimit(2)
|
Text(item.content).font(.system(size: 12)).foregroundColor(Color.zxF04).lineLimit(2)
|
||||||
Text(item.time).font(.system(size: 10)).foregroundColor(Color.zxF03)
|
if let createdAt = item.createdAt {
|
||||||
|
Text(createdAt.prefix(10).description).font(.system(size: 10)).foregroundColor(Color.zxF03)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(Color.zxF03)
|
Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(Color.zxF03)
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ProfileView: View {
|
struct ProfileView: View {
|
||||||
|
@StateObject private var viewModel = ProfileViewModel()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
ZXGradient.page.ignoresSafeArea()
|
ZXGradient.page.ignoresSafeArea()
|
||||||
@ -15,12 +17,14 @@ struct ProfileView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1))
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1))
|
||||||
}
|
}
|
||||||
|
.accessibilityLabel("通知中心")
|
||||||
NavigationLink(destination: SettingsView()) {
|
NavigationLink(destination: SettingsView()) {
|
||||||
Image(systemName: "gearshape").font(.system(size: 18)).foregroundColor(Color.zxF05)
|
Image(systemName: "gearshape").font(.system(size: 18)).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))
|
||||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1))
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1))
|
||||||
}
|
}
|
||||||
|
.accessibilityLabel("设置")
|
||||||
}.padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 4)
|
}.padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 4)
|
||||||
profileCard
|
profileCard
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@ -44,18 +48,25 @@ struct ProfileView: View {
|
|||||||
}.padding(.horizontal, 20)
|
}.padding(.horizontal, 20)
|
||||||
}.scrollIndicators(.hidden)
|
}.scrollIndicators(.hidden)
|
||||||
}
|
}
|
||||||
|
.task { await viewModel.loadAll() }
|
||||||
}
|
}
|
||||||
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)
|
||||||
|
.accessibilityLabel("编辑个人资料,\(profile?.nickname ?? "学习者")")
|
||||||
|
.accessibilityHint("双击查看和编辑个人资料")
|
||||||
}
|
}
|
||||||
private var achievementsSection: some View {
|
private var achievementsSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
@ -68,7 +79,7 @@ struct ZXProfileStat: View { let v: String; let l: String; let c: Color; var bod
|
|||||||
init(value: String, label: String, color: Color) { self.v = value; self.l = label; self.c = color }
|
init(value: String, label: String, color: Color) { self.v = value; self.l = label; self.c = color }
|
||||||
}
|
}
|
||||||
struct ZXProfileMenuRow: View { let emoji: String; let title: String; let desc: String
|
struct ZXProfileMenuRow: View { let emoji: String; let title: String; let desc: String
|
||||||
var body: some View { HStack(spacing: 12) { Text(emoji).font(.system(size: 20)).frame(width: 36, height: 36).background(Color.zxFill006).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(); Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(Color.zxF03) }.padding(.horizontal, 16).padding(.vertical, 14) }
|
var body: some View { HStack(spacing: 12) { Text(emoji).font(.system(size: 20)).frame(width: 36, height: 36).background(Color.zxFill006).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(); Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(Color.zxF03) }.padding(.horizontal, 16).padding(.vertical, 14).accessibilityLabel("\(title):\(desc)") }
|
||||||
}
|
}
|
||||||
struct ZXProfileDivider: View {
|
struct ZXProfileDivider: View {
|
||||||
var body: some View { Rectangle().fill(Color.zxBorder008).frame(height: 1).padding(.leading, 64) }
|
var body: some View { Rectangle().fill(Color.zxBorder008).frame(height: 1).padding(.leading, 64) }
|
||||||
|
|||||||
@ -0,0 +1,55 @@
|
|||||||
|
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
|
||||||
|
await loadProfile()
|
||||||
|
isLoading = false
|
||||||
|
Task { await loadActivitySummary() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadProfile() async {
|
||||||
|
do {
|
||||||
|
let profile = try await UserService.shared.myProfile()
|
||||||
|
userProfile = profile
|
||||||
|
preferences = profile.preferences
|
||||||
|
profileData = profile.profile
|
||||||
|
} catch {
|
||||||
|
if userProfile == nil { errorMessage = "加载用户信息失败" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePreferences(_ dto: UpdatePreferencesRequest) async {
|
||||||
|
do {
|
||||||
|
preferences = try await UserService.shared.updatePreferences(dto)
|
||||||
|
} catch {
|
||||||
|
errorMessage = "保存设置失败"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateProfileDetail(_ dto: UpdateProfileDataRequest) async {
|
||||||
|
do {
|
||||||
|
profileData = try await UserService.shared.updateProfileDetail(dto)
|
||||||
|
} catch {
|
||||||
|
errorMessage = "保存学习档案失败"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadActivitySummary() async {
|
||||||
|
do {
|
||||||
|
summary = try await ActivityService.shared.summary()
|
||||||
|
} catch {
|
||||||
|
// non-critical, stats remain at 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,13 +1,18 @@
|
|||||||
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 {
|
||||||
@ -18,9 +23,9 @@ struct SettingsView: View {
|
|||||||
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 +77,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)
|
||||||
@ -79,6 +106,15 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.scrollIndicators(.hidden)
|
.scrollIndicators(.hidden)
|
||||||
}
|
}
|
||||||
|
.task { await profileVM.loadProfile() }
|
||||||
|
.onChange(of: profileVM.preferences) { _, p in
|
||||||
|
guard let p else { return }
|
||||||
|
appearance = p.appearance ?? "system"
|
||||||
|
language = p.language ?? "zh-CN"
|
||||||
|
defaultFocusMinutes = p.defaultFocusMinutes ?? 25
|
||||||
|
notificationEnabled = p.notificationEnabled ?? true
|
||||||
|
reviewReminder = notificationEnabled
|
||||||
|
}
|
||||||
.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)
|
.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,12 +126,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 +180,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 +198,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 +217,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 +238,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 +254,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)
|
||||||
|
|||||||
@ -10,6 +10,8 @@ struct LearningSessionView: View {
|
|||||||
@State private var isRunning = true
|
@State private var isRunning = true
|
||||||
@State private var isPaused = false
|
@State private var isPaused = false
|
||||||
@State private var showEndConfirm = false
|
@State private var showEndConfirm = false
|
||||||
|
@State private var showCelebration = false
|
||||||
|
@State private var sessionEnded = false
|
||||||
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -38,9 +40,16 @@ struct LearningSessionView: View {
|
|||||||
if isRunning { elapsed += 1 }
|
if isRunning { elapsed += 1 }
|
||||||
}
|
}
|
||||||
.confirmationDialog("结束学习?", isPresented: $showEndConfirm, titleVisibility: .visible) {
|
.confirmationDialog("结束学习?", isPresented: $showEndConfirm, titleVisibility: .visible) {
|
||||||
Button("结束并保存", role: .destructive) { isRunning = false }
|
Button("结束并保存", role: .destructive) { isRunning = false; sessionEnded = true; showCelebration = true }
|
||||||
Button("继续学习", role: .cancel) {}
|
Button("继续学习", role: .cancel) {}
|
||||||
}
|
}
|
||||||
|
.overlay {
|
||||||
|
if showCelebration {
|
||||||
|
ZXCelebrationView(title: "学习完成", subtitle: "你已专注学习了 \(formatTime(elapsed)),继续保持!") {
|
||||||
|
withAnimation(.easeOut(duration: 0.3)) { showCelebration = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var timerCard: some View {
|
private var timerCard: some View {
|
||||||
@ -73,6 +82,8 @@ struct LearningSessionView: View {
|
|||||||
.background(ZXGradient.brandPurple)
|
.background(ZXGradient.brandPurple)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
}
|
}
|
||||||
|
.zxPressable()
|
||||||
|
.accessibilityLabel(isRunning ? "暂停学习" : "继续学习")
|
||||||
Button { showEndConfirm = true } label: {
|
Button { showEndConfirm = true } label: {
|
||||||
Label("结束", systemImage: "stop.fill")
|
Label("结束", systemImage: "stop.fill")
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.system(size: 14, weight: .semibold))
|
||||||
@ -82,6 +93,9 @@ struct LearningSessionView: View {
|
|||||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
|
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
}
|
}
|
||||||
|
.zxPressable()
|
||||||
|
.accessibilityLabel("结束学习")
|
||||||
|
.accessibilityHint("停止计时并保存本次学习记录")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(24)
|
.padding(24)
|
||||||
|
|||||||
@ -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: "偏差衡量模型预测与真实值的偏离程度,方差衡量模型在不同训练集上的预测波动。偏差-方差权衡指的是:简单模型偏差高方差低(欠拟合),复杂模型偏差低方差高(过拟合)。最佳模型需要在两者之间取得平衡。",
|
||||||
@ -17,6 +18,7 @@ struct ReviewCardView: View {
|
|||||||
@State private var flipped = false
|
@State private var flipped = false
|
||||||
@State private var rating: Int? = nil
|
@State private var rating: Int? = nil
|
||||||
@State private var finish = false
|
@State private var finish = false
|
||||||
|
@State private var showCelebration = false
|
||||||
|
|
||||||
var current: ReviewCardItem { cards[idx] }
|
var current: ReviewCardItem { cards[idx] }
|
||||||
|
|
||||||
@ -39,6 +41,14 @@ struct ReviewCardView: View {
|
|||||||
}
|
}
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbarBackground(.hidden, for: .navigationBar)
|
.toolbarBackground(.hidden, for: .navigationBar)
|
||||||
|
.task { await viewModel.loadDueCards() }
|
||||||
|
.overlay {
|
||||||
|
if showCelebration {
|
||||||
|
ZXCelebrationView(title: "复习完成", subtitle: "你已完成本次间隔复习,继续保持!") {
|
||||||
|
withAnimation(.easeOut(duration: 0.3)) { showCelebration = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var progressBar: some View {
|
private var progressBar: some View {
|
||||||
@ -103,6 +113,11 @@ struct ReviewCardView: View {
|
|||||||
.overlay(RoundedRectangle(cornerRadius: 20).stroke((flipped ? Color.zxPurple : Color.zxAccent).opacity(0.15), lineWidth: 1))
|
.overlay(RoundedRectangle(cornerRadius: 20).stroke((flipped ? Color.zxPurple : Color.zxAccent).opacity(0.15), lineWidth: 1))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||||
.onTapGesture { withAnimation(.easeInOut(duration: 0.4)) { flipped.toggle() } }
|
.onTapGesture { withAnimation(.easeInOut(duration: 0.4)) { flipped.toggle() } }
|
||||||
|
.zxPressable()
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel(flipped ? "答案:\(current.answer)" : "问题:\(current.question)")
|
||||||
|
.accessibilityHint(flipped ? "来源:\(current.source)" : "双击翻转查看答案")
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var ratingBar: some View {
|
private var ratingBar: some View {
|
||||||
@ -128,6 +143,7 @@ struct ReviewCardView: View {
|
|||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { idx += 1 }
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { idx += 1 }
|
||||||
} else {
|
} else {
|
||||||
finish = true
|
finish = true
|
||||||
|
showCelebration = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -155,5 +171,8 @@ struct ZXRatingBtn: View {
|
|||||||
if !selected { RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1) }
|
if !selected { RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.zxPressable()
|
||||||
|
.accessibilityLabel("\(label)")
|
||||||
|
.accessibilityHint("将此卡片标记为\(label)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,59 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ReviewTask: Identifiable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let userId: String
|
||||||
|
let lessonId: String
|
||||||
|
let sourceSessionId: String
|
||||||
|
let reviewType: ReviewType
|
||||||
|
let scheduledAt: String
|
||||||
|
let completedAt: String?
|
||||||
|
var status: ReviewTaskStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReviewType: String {
|
||||||
|
case recall, feynman, review
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReviewTaskStatus: String {
|
||||||
|
case pending, completed
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ReviewPlanViewModel: ObservableObject {
|
||||||
|
@Published var todayTasks: [ReviewTask] = [
|
||||||
|
ReviewTask(id: "t1", userId: "u1", lessonId: "l1", sourceSessionId: "s1",
|
||||||
|
reviewType: .recall, scheduledAt: "", completedAt: nil, status: .pending),
|
||||||
|
ReviewTask(id: "t2", userId: "u1", lessonId: "l2", sourceSessionId: "s2",
|
||||||
|
reviewType: .feynman, scheduledAt: "", completedAt: nil, status: .completed),
|
||||||
|
ReviewTask(id: "t3", userId: "u1", lessonId: "l3", sourceSessionId: "s3",
|
||||||
|
reviewType: .review, scheduledAt: "", completedAt: nil, status: .pending),
|
||||||
|
]
|
||||||
|
|
||||||
|
@Published var tomorrowTasks: [ReviewTask] = [
|
||||||
|
ReviewTask(id: "t4", userId: "u1", lessonId: "l4", sourceSessionId: "s4",
|
||||||
|
reviewType: .recall, scheduledAt: "", completedAt: nil, status: .pending),
|
||||||
|
ReviewTask(id: "t5", userId: "u1", lessonId: "l5", sourceSessionId: "s5",
|
||||||
|
reviewType: .feynman, scheduledAt: "", completedAt: nil, status: .pending),
|
||||||
|
]
|
||||||
|
|
||||||
|
@Published var weekTasks: [ReviewTask] = [
|
||||||
|
ReviewTask(id: "t6", userId: "u1", lessonId: "l6", sourceSessionId: "s6",
|
||||||
|
reviewType: .review, scheduledAt: "", completedAt: nil, status: .pending),
|
||||||
|
ReviewTask(id: "t7", userId: "u1", lessonId: "l7", sourceSessionId: "s7",
|
||||||
|
reviewType: .recall, scheduledAt: "", completedAt: nil, status: .completed),
|
||||||
|
]
|
||||||
|
|
||||||
|
var totalCount: Int { todayTasks.count + tomorrowTasks.count + weekTasks.count }
|
||||||
|
|
||||||
|
func toggleTask(_ task: ReviewTask) {
|
||||||
|
for list in [\ReviewPlanViewModel.todayTasks, \.tomorrowTasks, \.weekTasks] {
|
||||||
|
if let idx = self[keyPath: list].firstIndex(where: { $0.id == task.id }) {
|
||||||
|
let current = self[keyPath: list][idx].status
|
||||||
|
self[keyPath: list][idx].status = current == .completed ? .pending : .completed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
AIStudyApp/AIStudyApp/Features/Study/ReviewViewModel.swift
Normal file
48
AIStudyApp/AIStudyApp/Features/Study/ReviewViewModel.swift
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class ReviewViewModel: ObservableObject {
|
||||||
|
@Published var dueCards: [ReviewCard] = []
|
||||||
|
@Published var currentCardIndex = 0
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
|
||||||
|
var currentCard: ReviewCard? {
|
||||||
|
guard currentCardIndex < dueCards.count else { return nil }
|
||||||
|
return dueCards[currentCardIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
var isComplete: Bool {
|
||||||
|
currentCardIndex >= dueCards.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var progress: Double {
|
||||||
|
guard !dueCards.isEmpty else { return 0 }
|
||||||
|
return Double(currentCardIndex) / Double(dueCards.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDueCards() async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
do {
|
||||||
|
dueCards = try await ReviewService.shared.dueCards()
|
||||||
|
currentCardIndex = 0
|
||||||
|
} catch {
|
||||||
|
errorMessage = "加载复习卡片失败"
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func submitRating(_ rating: String, responseText: String? = nil) async {
|
||||||
|
guard let card = currentCard else { return }
|
||||||
|
do {
|
||||||
|
_ = try await ReviewService.shared.submit(
|
||||||
|
id: card.id, rating: rating, responseText: responseText
|
||||||
|
)
|
||||||
|
currentCardIndex += 1
|
||||||
|
} catch {
|
||||||
|
errorMessage = "提交复习结果失败"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,25 +1,20 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct StudyHomeView: View {
|
struct StudyHomeView: View {
|
||||||
@State private var ts: [ZXSTask] = [
|
@StateObject private var studyVM = StudyViewModel()
|
||||||
.init(t: "机器学习 - 回忆测试", tp: "回忆测试", c: Color.zxPurple, m: 10, d: true),
|
@StateObject private var studyHomeVM = StudyHomeViewModel()
|
||||||
.init(t: "高数 - 间隔复习 8 题", tp: "间隔复习", c: Color.zxOrange, m: 15, d: true),
|
@StateObject private var reviewVM = ReviewViewModel()
|
||||||
.init(t: "英语词汇 - 25 个待复习", tp: "词汇复习", c: Color.zxTeal, m: 8, d: false),
|
|
||||||
.init(t: "注意力机制 - 费曼解释", tp: "费曼练习", c: Color.zxAccent, m: 12, d: false),
|
|
||||||
.init(t: "产品设计 - 薄弱点复习", tp: "薄弱点", c: Color.zxYellow, m: 10, d: false),
|
|
||||||
]
|
|
||||||
private let wb: [CGFloat] = [0.3, 0.7, 1.0, 0.4, 0.9, 0.6, 0.2]
|
|
||||||
private let dl = ["一","二","三","四","五","六","日"]
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack { ZXGradient.page.ignoresSafeArea()
|
ZStack { ZXGradient.page.ignoresSafeArea()
|
||||||
ScrollView { VStack(spacing: 16) {
|
ScrollView { VStack(spacing: 16) {
|
||||||
HStack { VStack(alignment: .leading, spacing: 2) { Text("周四,1月16日").font(.system(size: 12, weight: .medium)).foregroundColor(Color.zxF04); Text("学习工作台").font(.system(size: 20, weight: .heavy)).foregroundColor(Color.zxF0).tracking(-0.4) }; Spacer()
|
HStack { VStack(alignment: .leading, spacing: 2) { Text("学习工作台").font(.system(size: 20, weight: .heavy)).foregroundColor(Color.zxF0).tracking(-0.4) }; Spacer()
|
||||||
|
if studyVM.isLoading { ZXLoadingView(size: 22, lineWidth: 2) }
|
||||||
HStack(spacing: 4) { Image(systemName: "flame.fill").font(.system(size: 14)).foregroundColor(Color.zxOrange); Text("14 天连续").font(.system(size: 13, weight: .bold)).foregroundColor(Color.zxOrange) }.padding(.horizontal, 12).padding(.vertical, 6).background(Color.zxOrangeBG(0.1)).clipShape(Capsule()).overlay(Capsule().stroke(Color(hex: "#F97316", opacity: 0.2), lineWidth: 1)) }
|
HStack(spacing: 4) { Image(systemName: "flame.fill").font(.system(size: 14)).foregroundColor(Color.zxOrange); Text("14 天连续").font(.system(size: 13, weight: .bold)).foregroundColor(Color.zxOrange) }.padding(.horizontal, 12).padding(.vertical, 6).background(Color.zxOrangeBG(0.1)).clipShape(Capsule()).overlay(Capsule().stroke(Color(hex: "#F97316", opacity: 0.2), lineWidth: 1)) }
|
||||||
.padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 4)
|
.padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 4)
|
||||||
pc
|
pc
|
||||||
VStack(alignment: .leading, spacing: 12) { HStack { Text("今日任务").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0); Spacer(); HStack(spacing: 4) { Image(systemName: "calendar").font(.system(size: 12)).foregroundColor(Color.zxF04); Text("AI 自动排期").font(.system(size: 12)).foregroundColor(Color.zxF04) } }
|
VStack(alignment: .leading, spacing: 12) { HStack { Text("今日任务").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0); Spacer(); HStack(spacing: 4) { Image(systemName: "calendar").font(.system(size: 12)).foregroundColor(Color.zxF04); Text("AI 自动排期").font(.system(size: 12)).foregroundColor(Color.zxF04) } }
|
||||||
ForEach($ts) { $t in
|
ForEach($studyHomeVM.tasks) { $t in
|
||||||
if t.tp == "回忆测试" {
|
if t.tp == "回忆测试" {
|
||||||
NavigationLink(destination: ActiveRecallView()) { ZXSTaskRowView(task: t) { t.d.toggle() } }.foregroundColor(.primary)
|
NavigationLink(destination: ActiveRecallView()) { ZXSTaskRowView(task: t) { t.d.toggle() } }.foregroundColor(.primary)
|
||||||
} else if t.tp == "费曼练习" {
|
} else if t.tp == "费曼练习" {
|
||||||
@ -34,13 +29,16 @@ struct StudyHomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
VStack(alignment: .leading, spacing: 14) { Text("本周学习活跃").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0)
|
VStack(alignment: .leading, spacing: 14) { Text("本周学习活跃").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0)
|
||||||
HStack(alignment: .bottom, spacing: 8) { ForEach(0..<7, id: \.self) { i in VStack(spacing: 8) { RoundedRectangle(cornerRadius: 6).fill(i == 6 ? Color.zxFill01 : Color(hex: "#7C6EFA", opacity: wb[i] * 0.9 + 0.1)).frame(height: wb[i] * 60); Text(dl[i]).font(.system(size: 10, weight: i == 2 ? .bold : .regular)).foregroundColor(i == 2 ? Color.zxPurple : Color.zxF03) }.frame(maxWidth: .infinity) } }
|
HStack(alignment: .bottom, spacing: 8) { ForEach(0..<7, id: \.self) { i in VStack(spacing: 8) { RoundedRectangle(cornerRadius: 6).fill(i == 6 ? Color.zxFill01 : Color(hex: "#7C6EFA", opacity: studyHomeVM.weekActivity[i] * 0.9 + 0.1)).frame(height: studyHomeVM.weekActivity[i] * 60); Text(studyHomeVM.dayLabels[i]).font(.system(size: 10, weight: i == 2 ? .bold : .regular)).foregroundColor(i == 2 ? Color.zxPurple : Color.zxF03) }.frame(maxWidth: .infinity) } }
|
||||||
HStack { Text("总计 3.5 小时").font(.system(size: 11)).foregroundColor(Color.zxF03); Spacer(); Text("日均 30 分钟").font(.system(size: 11)).foregroundColor(Color.zxF03) } }
|
HStack { Text("总计 3.5 小时").font(.system(size: 11)).foregroundColor(Color.zxF03); Spacer(); Text("日均 30 分钟").font(.system(size: 11)).foregroundColor(Color.zxF03) } }
|
||||||
.padding(.bottom, 120) }
|
.padding(.bottom, 120) }
|
||||||
.padding(.horizontal, 20) }
|
.padding(.horizontal, 20) }
|
||||||
.scrollIndicators(.hidden) }
|
.scrollIndicators(.hidden)
|
||||||
|
.zxPullToRefresh { await studyVM.loadSessions() }
|
||||||
|
}
|
||||||
|
.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 = studyHomeVM.doneCount; 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()
|
||||||
ZStack { Circle().trim(from: 0, to: pct).stroke(ZXGradient.brand, style: StrokeStyle(lineWidth: 8, lineCap: .round)).rotationEffect(.degrees(-90)).frame(width: 64, height: 64); Text("\(Int(pct * 100))%").font(.system(size: 14, weight: .heavy)).foregroundColor(Color.zxPurple) } }
|
ZStack { Circle().trim(from: 0, to: pct).stroke(ZXGradient.brand, style: StrokeStyle(lineWidth: 8, lineCap: .round)).rotationEffect(.degrees(-90)).frame(width: 64, height: 64); Text("\(Int(pct * 100))%").font(.system(size: 14, weight: .heavy)).foregroundColor(Color.zxPurple) } }
|
||||||
ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 3).fill(Color.zxFill008).frame(height: 6); RoundedRectangle(cornerRadius: 3).fill(LinearGradient(colors: [Color.zxPurple, Color.zxAccent], startPoint: .leading, endPoint: .trailing)).frame(width: max(6, pct * (UIScreen.main.bounds.width - 72)), height: 6) }
|
ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 3).fill(Color.zxFill008).frame(height: 6); RoundedRectangle(cornerRadius: 3).fill(LinearGradient(colors: [Color.zxPurple, Color.zxAccent], startPoint: .leading, endPoint: .trailing)).frame(width: max(6, pct * (UIScreen.main.bounds.width - 72)), height: 6) }
|
||||||
@ -56,5 +54,8 @@ struct ZXSTaskRowView: View { let task: ZXSTask; var action: () -> Void
|
|||||||
var body: some View { HStack(spacing: 12) { Image(systemName: task.d ? "checkmark.circle.fill" : "circle").font(.system(size: 20)).foregroundColor(task.d ? Color.zxGreen : Color.zxF02)
|
var body: some View { HStack(spacing: 12) { Image(systemName: task.d ? "checkmark.circle.fill" : "circle").font(.system(size: 20)).foregroundColor(task.d ? Color.zxGreen : Color.zxF02)
|
||||||
VStack(alignment: .leading, spacing: 4) { Text(task.t).font(.system(size: 13, weight: .semibold)).foregroundColor(task.d ? Color.zxF04 : Color.zxF0).strikethrough(task.d); HStack(spacing: 8) { Text(task.tp).font(.system(size: 10, weight: .semibold)).foregroundColor(task.c).padding(.horizontal, 6).padding(.vertical, 1).background(task.c.opacity(0.12)).clipShape(Capsule()); Text("约 \(task.m) 分钟").font(.system(size: 10)).foregroundColor(Color(hex:"#F0F0FF",opacity:0.35)) } }
|
VStack(alignment: .leading, spacing: 4) { Text(task.t).font(.system(size: 13, weight: .semibold)).foregroundColor(task.d ? Color.zxF04 : Color.zxF0).strikethrough(task.d); HStack(spacing: 8) { Text(task.tp).font(.system(size: 10, weight: .semibold)).foregroundColor(task.c).padding(.horizontal, 6).padding(.vertical, 1).background(task.c.opacity(0.12)).clipShape(Capsule()); Text("约 \(task.m) 分钟").font(.system(size: 10)).foregroundColor(Color(hex:"#F0F0FF",opacity:0.35)) } }
|
||||||
Spacer(); if !task.d { Image(systemName: "play.fill").font(.system(size: 14)).foregroundColor(.white).frame(width: 32, height: 32).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: 10)) } }
|
Spacer(); if !task.d { Image(systemName: "play.fill").font(.system(size: 14)).foregroundColor(.white).frame(width: 32, height: 32).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: 10)) } }
|
||||||
.padding(.horizontal, 16).padding(.vertical, 12).background(task.d ? Color.zxFill003 : Color.zxFill005).overlay(RoundedRectangle(cornerRadius: 14).stroke(task.d ? Color(hex: "#FFFFFF", opacity: 0.05) : Color.zxBorder008, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 14)).opacity(task.d ? 0.6 : 1).contentShape(Rectangle()).onTapGesture { action() } }
|
.padding(.horizontal, 16).padding(.vertical, 12).background(task.d ? Color.zxFill003 : Color.zxFill005).overlay(RoundedRectangle(cornerRadius: 14).stroke(task.d ? Color(hex: "#FFFFFF", opacity: 0.05) : Color.zxBorder008, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 14)).opacity(task.d ? 0.6 : 1).contentShape(Rectangle()).onTapGesture { action() }.zxPressable()
|
||||||
|
.accessibilityLabel("\(task.t), \(task.tp), 约\(task.m)分钟")
|
||||||
|
.accessibilityAddTraits(task.d ? .isSelected : [])
|
||||||
|
.accessibilityHint(task.d ? "已完成" : "双击开始学习") }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,26 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class StudyHomeViewModel: ObservableObject {
|
||||||
|
@Published var tasks: [ZXSTask] = [
|
||||||
|
ZXSTask(t: "机器学习 - 回忆测试", tp: "回忆测试", c: .zxPurple, m: 10, d: true),
|
||||||
|
ZXSTask(t: "高数 - 间隔复习 8 题", tp: "间隔复习", c: .zxOrange, m: 15, d: true),
|
||||||
|
ZXSTask(t: "英语词汇 - 25 个待复习", tp: "词汇复习", c: .zxTeal, m: 8, d: false),
|
||||||
|
ZXSTask(t: "注意力机制 - 费曼解释", tp: "费曼练习", c: .zxAccent, m: 12, d: false),
|
||||||
|
ZXSTask(t: "产品设计 - 薄弱点复习", tp: "薄弱点", c: .zxYellow, m: 10, d: false),
|
||||||
|
]
|
||||||
|
|
||||||
|
@Published var weekActivity: [CGFloat] = [0.3, 0.7, 1.0, 0.4, 0.9, 0.6, 0.2]
|
||||||
|
let dayLabels = ["一", "二", "三", "四", "五", "六", "日"]
|
||||||
|
|
||||||
|
var doneCount: Int { tasks.filter(\.d).count }
|
||||||
|
var progress: Double { tasks.isEmpty ? 0 : Double(doneCount) / Double(tasks.count) }
|
||||||
|
var doneMinutes: Int { tasks.filter(\.d).map(\.m).reduce(0, +) }
|
||||||
|
var remainingMinutes: Int { tasks.filter { !$0.d }.map(\.m).reduce(0, +) }
|
||||||
|
|
||||||
|
func toggleTask(_ task: ZXSTask) {
|
||||||
|
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||||
|
tasks[idx].d.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
42
AIStudyApp/AIStudyApp/Features/Study/StudyViewModel.swift
Normal file
42
AIStudyApp/AIStudyApp/Features/Study/StudyViewModel.swift
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class StudyViewModel: ObservableObject {
|
||||||
|
@Published var sessions: [LearningSession] = []
|
||||||
|
@Published var currentSession: LearningSession?
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
|
||||||
|
func loadSessions() async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
do {
|
||||||
|
sessions = try await LearningSessionService.shared.list()
|
||||||
|
} catch {
|
||||||
|
errorMessage = "加载学习会话失败"
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func startSession(knowledgeBaseId: String? = nil, mode: String? = nil) async {
|
||||||
|
do {
|
||||||
|
currentSession = try await LearningSessionService.shared.start(
|
||||||
|
knowledgeBaseId: knowledgeBaseId, mode: mode
|
||||||
|
)
|
||||||
|
sessions.insert(currentSession!, at: 0)
|
||||||
|
} catch {
|
||||||
|
errorMessage = "开始学习会话失败"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func endCurrentSession() async {
|
||||||
|
guard let session = currentSession else { return }
|
||||||
|
do {
|
||||||
|
_ = try await LearningSessionService.shared.end(id: session.id)
|
||||||
|
currentSession = nil
|
||||||
|
} catch {
|
||||||
|
errorMessage = "结束学习会话失败"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user