Compare commits

...

3 Commits

Author SHA1 Message Date
89d89e542c feat(ios): P2 动效补充 + 无障碍适配
- 新增 ZXAnimations.swift — ZXButtonStyle/ZXPressModifier/ZXPageTransition/ZXThinkingOverlay/ZXCelebrationView/ZXAIAnalysisProgress
- 新增 ZXLoadingView.swift — 品牌化加载动画/ZXDotLoader/ZXShimmer
- 新增 ZXRefreshableScrollView.swift — 下拉刷新+上拉加载更多
- 新增 ZXToast.swift — 全局 Toast 通知系统
- 新增 FileCache.swift / LocalCache.swift — 本地缓存层
- 新增 AIChatViewModel.swift / StudyHomeViewModel.swift / ReviewPlanViewModel.swift
- 全部关键按钮接入 .zxPressable() 触觉反馈
- AI 分析流程接入 ZXThinkingOverlay + ZXAIAnalysisProgress
- 学习完成/复习完成接入 ZXCelebrationView 庆祝动画
- 全部关键交互元素添加 .accessibilityLabel
- 修复 ProfileViewModel async let 问题、EditProfilePage 保存失败、let _ = Task{} 反模式

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 22:31:24 +08:00
b182203464 fix(ios): APIClient 兼容有无 ResponseInterceptor 两种响应格式
服务器部署版本可能未启用 ResponseInterceptor,返回裸 JSON 而非
{ success, data, timestamp } 信封格式。APIClient 解码时先探测
JSON 结构,自动适配包裹/裸数据两种格式。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 19:42:17 +08:00
5e19bd740e feat(ios): 鉴权系统完善 + 前后端打通 + 模型对齐 + ViewModel 创建
- 新增 AuthManager (ObservableObject) 集中管理鉴权状态:
  - session 恢复 → token 验证 → 自动刷新
  - 登出自动重定向到登录页
  - NotificationCenter 监听 401 实现全局踢回
- APIClient 新增 401 自动 refresh + 单次重试
- App.swift 重构鉴权门控:
  - 去掉 hasCompletedOnboarding 绕过鉴权漏洞
  - 拆分为 SplashScreen / PreLoginFlow / PostLoginOnboardingFlow / ContentView
  - LoginPage 移除"跳过"按钮
- KeychainHelper 实现 token 安全存储
- APIModels 对齐后端 Prisma schema (UserProfile/KnowledgeBase/ReviewCard 等)
- APIService 简化 AuthService,token 管理迁移至 AuthManager
- 新增 8 个 ViewModel 接入 API:
  ProfileViewModel, LibraryViewModel, StudyViewModel,
  ActiveRecallViewModel, AIAnalysisViewModel, ReviewViewModel, ActivityViewModel
- 新增 EditProfilePage 编辑资料页
- 新增 NotificationListView 通知列表页
- AIHomeView 修复"检测中"卡住 (改用公开 GET / 健康检查)
- SettingsView 登出调用 AuthManager.signOut() 实现重定向
- 修复 NotificationItem 命名冲突、Combine import 缺失等编译错误

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 19:06:23 +08:00
38 changed files with 2881 additions and 404 deletions

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
</dict>
</plist>

View File

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

View File

@ -1,9 +1,11 @@
import SwiftUI import SwiftUI
import AuthenticationServices
@main @main
struct AIStudyAppApp: App { struct AIStudyAppApp: App {
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
@AppStorage("appAppearance") private var appAppearance = "system" @AppStorage("appAppearance") private var appAppearance = "system"
@StateObject private var authManager = AuthManager()
private var effectiveColorScheme: ColorScheme? { private var effectiveColorScheme: ColorScheme? {
switch appAppearance { switch appAppearance {
@ -15,102 +17,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)
} } }
}
} }

View File

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

View File

@ -0,0 +1,103 @@
import SwiftUI
import Combine
extension Notification.Name {
static let tokenExpired = Notification.Name("cloud.longde.AIStudyApp.tokenExpired")
}
@MainActor
final class AuthManager: ObservableObject {
@Published var isAuthenticated = false
@Published var isRestoring = true
static let shared = AuthManager()
private var tokenExpiredObserver: NSObjectProtocol?
init() {
tokenExpiredObserver = NotificationCenter.default.addObserver(
forName: .tokenExpired, object: nil, queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.handleUnauthorized()
}
}
}
deinit {
if let observer = tokenExpiredObserver {
NotificationCenter.default.removeObserver(observer)
}
}
func restoreSession() async {
isRestoring = true
defer { isRestoring = false }
guard let token = KeychainHelper.getAccessToken() else {
isAuthenticated = false
return
}
await APIClient.shared.setToken(token)
do {
let _: UserProfileResponse = try await APIClient.shared.request("/users/me")
isAuthenticated = true
} catch {
if let refreshed = await tryRefresh() {
await APIClient.shared.setToken(refreshed.accessToken)
KeychainHelper.save(
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken,
userId: refreshed.user?.id ?? ""
)
isAuthenticated = true
} else {
await APIClient.shared.setToken(nil)
KeychainHelper.clear()
isAuthenticated = false
}
}
}
func signIn(_ response: AuthResponse) async {
await APIClient.shared.setToken(response.accessToken)
KeychainHelper.save(
accessToken: response.accessToken,
refreshToken: response.refreshToken,
userId: response.user?.id ?? ""
)
isAuthenticated = true
}
func signOut() async {
if let refreshToken = KeychainHelper.getRefreshToken() {
let body = RefreshRequest(refreshToken: refreshToken)
let _: GenericSuccessResponse? = try? await APIClient.shared.request(
"/auth/logout", method: "POST", body: body
)
}
await APIClient.shared.setToken(nil)
KeychainHelper.clear()
isAuthenticated = false
}
private func handleUnauthorized() {
Task {
await APIClient.shared.setToken(nil)
KeychainHelper.clear()
isAuthenticated = false
}
}
private func tryRefresh() async -> AuthResponse? {
guard let refreshToken = KeychainHelper.getRefreshToken() else { return nil }
do {
let body = RefreshRequest(refreshToken: refreshToken)
return try await APIClient.shared.request("/auth/refresh", method: "POST", body: body)
} catch {
return nil
}
}
}

View File

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

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

View File

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

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

View File

@ -1,20 +1,41 @@
// //
// Models.swift - api-server DTO // APIModels.swift - api-server
// //
import Foundation import Foundation
// MARK: - API Envelope (ResponseInterceptor wraps all responses)
struct APIEnvelope<T: Decodable>: Decodable {
let success: Bool
let data: T
let timestamp: String?
}
// MARK: - Pagination
struct PaginationMeta: Codable {
let page: Int
let limit: Int
let total: Int
}
struct PaginatedResponse<T: Decodable>: Decodable {
let data: [T]
let meta: PaginationMeta
}
// MARK: - Waitlist // MARK: - Waitlist
struct WaitlistEntry: Codable, Identifiable { struct WaitlistEntry: Codable, Identifiable {
let id: String let id: String
let email: String
let nickname: String? let nickname: String?
let email: String
let devices: [String]? let devices: [String]?
let interests: [String]? let interests: [String]?
let painpoint: String? let painpoint: String?
let willingBeta: Bool? let willingBeta: Bool?
let createdAt: String let createdAt: String?
} }
struct WaitlistCreateRequest: Codable { struct WaitlistCreateRequest: Codable {
@ -37,7 +58,7 @@ struct WaitlistCreateRequest: Codable {
} }
struct WaitlistResponse: Codable { struct WaitlistResponse: Codable {
let success: Bool let success: Bool?
let message: String? let message: String?
let data: WaitlistEntry? let data: WaitlistEntry?
} }
@ -47,29 +68,24 @@ struct WaitlistStats: Codable {
let today: Int? let today: Int?
let deviceBreakdown: [String: Int]? let deviceBreakdown: [String: Int]?
let interestBreakdown: [String: Int]? let interestBreakdown: [String: Int]?
enum CodingKeys: String, CodingKey {
case total, today
case deviceBreakdown = "deviceBreakdown"
case interestBreakdown = "interestBreakdown"
}
} }
// MARK: - Auth // MARK: - Auth
struct AuthResponse: Codable { struct AuthResponse: Codable {
let success: Bool
let data: AuthTokens?
}
struct AuthTokens: Codable {
let accessToken: String let accessToken: String
let refreshToken: String? let refreshToken: String?
let expiresIn: Int? let user: AuthUser?
}
enum CodingKeys: String, CodingKey { struct AuthUser: Codable, Identifiable {
case accessToken, refreshToken, expiresIn let id: String
} let email: String?
let nickname: String?
let avatarUrl: String?
let role: String?
let status: String?
let onboardingCompleted: Bool?
} }
struct AppleAuthRequest: Codable { struct AppleAuthRequest: Codable {
@ -82,47 +98,70 @@ struct AppleAuthRequest: Codable {
} }
} }
// MARK: - User struct RefreshRequest: Codable {
let refreshToken: String
struct UserProfileResponse: Codable {
let success: Bool
let data: UserProfileData?
} }
struct UserProfileData: Codable, Identifiable { // MARK: - User Profile (matches GET /users/me with include: profile + preferences)
struct UserProfileResponse: Codable, Identifiable {
let id: String let id: String
let email: String let email: String?
let nickname: String? let nickname: String?
let avatar: String? let avatarUrl: String?
let preferences: UserPreferences? let role: String?
let stats: UserStats? let status: String?
let onboardingCompleted: Bool?
let createdAt: String? let createdAt: String?
} let profile: UserProfileData?
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?
} }

View File

@ -28,6 +28,16 @@ actor APIClient {
method: String = "GET", method: String = "GET",
body: Encodable? = nil, body: Encodable? = nil,
queryItems: [URLQueryItem]? = nil queryItems: [URLQueryItem]? = nil
) async throws -> T {
try await performRequest(path, method: method, body: body, queryItems: queryItems, isRetry: false)
}
private func performRequest<T: Decodable>(
_ path: String,
method: String,
body: Encodable?,
queryItems: [URLQueryItem]?,
isRetry: Bool
) async throws -> T { ) async throws -> T {
var components = URLComponents(url: APIConfig.url(path), resolvingAgainstBaseURL: true)! var components = URLComponents(url: APIConfig.url(path), resolvingAgainstBaseURL: true)!
if let queryItems { components.queryItems = queryItems } if let queryItems { components.queryItems = queryItems }
@ -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

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

View File

@ -0,0 +1,62 @@
import Foundation
import Security
enum KeychainHelper {
private static let service = "cloud.longde.AIStudyApp"
private static let accessTokenKey = "accessToken"
private static let refreshTokenKey = "refreshToken"
private static let userIdKey = "userId"
static func save(accessToken: String, refreshToken: String?, userId: String) {
save(key: accessTokenKey, value: accessToken)
if let rt = refreshToken { save(key: refreshTokenKey, value: rt) }
save(key: userIdKey, value: userId)
}
static func getAccessToken() -> String? { get(key: accessTokenKey) }
static func getRefreshToken() -> String? { get(key: refreshTokenKey) }
static func getUserId() -> String? { get(key: userIdKey) }
static func clear() {
delete(key: accessTokenKey)
delete(key: refreshTokenKey)
delete(key: userIdKey)
}
// MARK: - Private
private static func save(key: String, value: String) {
let data = Data(value.utf8)
delete(key: key)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data,
]
SecItemAdd(query as CFDictionary, nil)
}
private static func get(key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
private static func delete(key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
SecItemDelete(query as CFDictionary)
}
}

View File

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

View File

@ -12,7 +12,7 @@ class WaitlistService {
private let client = APIClient.shared private let client = APIClient.shared
func join(email: String, nickname: String?, devices: [String]?, func join(email: String, nickname: String?, devices: [String]?,
interests: [String]?, painpoint: String?, willingBeta: Bool = true) async throws -> WaitlistResponse { interests: [String]?, painpoint: String?, willingBeta: Bool = true) async throws -> WaitlistEntry {
let body = WaitlistCreateRequest(email: email, nickname: nickname, devices: devices, let body = WaitlistCreateRequest(email: email, nickname: nickname, devices: devices,
interests: interests, painpoint: painpoint, willingBeta: willingBeta) interests: interests, painpoint: painpoint, willingBeta: willingBeta)
return try await client.request("/waitlist", method: "POST", body: body) return try await client.request("/waitlist", method: "POST", body: body)
@ -37,16 +37,7 @@ class AuthService {
? AppleAuthRequest.AppleFullName(givenName: givenName, familyName: familyName) ? AppleAuthRequest.AppleFullName(givenName: givenName, familyName: familyName)
: nil : nil
) )
let resp: AuthResponse = try await client.request("/auth/apple", method: "POST", body: body) return try await client.request("/auth/apple", method: "POST", body: body)
if let token = resp.data?.accessToken {
await client.setToken(token)
}
return resp
}
func logout() async throws {
let _: GenericSuccessResponse = try await client.request("/auth/logout", method: "POST")
await client.setToken(nil)
} }
} }
@ -61,9 +52,20 @@ class UserService {
return try await client.request("/users/me") return try await client.request("/users/me")
} }
func updateProfile(nickname: String?, preferences: UserPreferences?) async throws -> UserProfileResponse { func updateProfile(_ dto: UpdateProfileRequest) async throws -> UserProfileResponse {
let body = UpdateUserRequest(nickname: nickname, preferences: preferences) return try await client.request("/users/me", method: "PATCH", body: dto)
return try await client.request("/users/me", method: "PATCH", body: body) }
func updatePreferences(_ dto: UpdatePreferencesRequest) async throws -> UserPreferences {
return try await client.request("/users/me/preferences", method: "PATCH", body: dto)
}
func getProfileDetail() async throws -> UserProfileData {
return try await client.request("/users/me/profile")
}
func updateProfileDetail(_ dto: UpdateProfileDataRequest) async throws -> UserProfileData {
return try await client.request("/users/me/profile", method: "PATCH", body: dto)
} }
} }
@ -74,18 +76,30 @@ class KnowledgeBaseService {
static let shared = KnowledgeBaseService() static let shared = KnowledgeBaseService()
private let client = APIClient.shared private let client = APIClient.shared
func list() async throws -> KnowledgeBaseListResponse { func list(page: Int = 1, limit: Int = 20) async throws -> [KnowledgeBase] {
return try await client.request("/knowledge-bases") return try await client.request("/knowledge-bases", queryItems: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "limit", value: String(limit)),
])
} }
func create(name: String, description: String?, icon: String?) async throws -> KnowledgeBaseListResponse { func create(title: String, description: String?) async throws -> KnowledgeBase {
let body = CreateKnowledgeBaseRequest(name: name, description: description, icon: icon) let body = CreateKnowledgeBaseRequest(title: title, description: description)
return try await client.request("/knowledge-bases", method: "POST", body: body) return try await client.request("/knowledge-bases", method: "POST", body: body)
} }
func detail(id: String) async throws -> KnowledgeBaseListResponse { func detail(id: String) async throws -> KnowledgeBase {
return try await client.request("/knowledge-bases/\(id)") return try await client.request("/knowledge-bases/\(id)")
} }
func update(id: String, title: String?, description: String?) async throws -> KnowledgeBase {
let body = CreateKnowledgeBaseRequest(title: title ?? "", description: description)
return try await client.request("/knowledge-bases/\(id)", method: "PATCH", body: body)
}
func delete(id: String) async throws -> GenericSuccessResponse {
return try await client.request("/knowledge-bases/\(id)", method: "DELETE")
}
} }
// MARK: - Knowledge Items // MARK: - Knowledge Items
@ -95,18 +109,50 @@ class KnowledgeItemService {
static let shared = KnowledgeItemService() static let shared = KnowledgeItemService()
private let client = APIClient.shared private let client = APIClient.shared
func list(baseId: String) async throws -> KnowledgeItemListResponse { func list(knowledgeBaseId: String) async throws -> [KnowledgeItem] {
return try await client.request("/knowledge-items", queryItems: [URLQueryItem(name: "baseId", value: baseId)]) return try await client.request("/knowledge-items", queryItems: [
URLQueryItem(name: "knowledgeBaseId", value: knowledgeBaseId),
])
} }
func detail(id: String) async throws -> KnowledgeItemListResponse { func detail(id: String) async throws -> KnowledgeItem {
return try await client.request("/knowledge-items/\(id)") return try await client.request("/knowledge-items/\(id)")
} }
func create(baseId: String, title: String, content: String?, tags: [String]?) async throws -> KnowledgeItemListResponse { func create(knowledgeBaseId: String, title: String, content: String?, itemType: String? = nil) async throws -> KnowledgeItem {
let body = CreateKnowledgeItemRequest(title: title, content: content, baseId: baseId, tags: tags) let body = CreateKnowledgeItemRequest(
knowledgeBaseId: knowledgeBaseId,
title: title,
content: content,
itemType: itemType
)
return try await client.request("/knowledge-items", method: "POST", body: body) return try await client.request("/knowledge-items", method: "POST", body: body)
} }
func update(id: String, title: String?, content: String?, summary: String?) async throws -> KnowledgeItem {
let body = UpdateKnowledgeItemRequest(title: title, content: content, summary: summary)
return try await client.request("/knowledge-items/\(id)", method: "PATCH", body: body)
}
}
// MARK: - Active Recall
@MainActor
class ActiveRecallService {
static let shared = ActiveRecallService()
private let client = APIClient.shared
func questions(page: Int = 1, limit: Int = 20) async throws -> [ActiveRecallQuestion] {
return try await client.request("/active-recalls", queryItems: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "limit", value: String(limit)),
])
}
func submit(questionId: String, answerText: String) async throws -> ActiveRecallAnswer {
let body = SubmitAnswerRequest(answerText: answerText)
return try await client.request("/active-recalls/\(questionId)/submit", method: "POST", body: body)
}
} }
// MARK: - AI Analysis // MARK: - AI Analysis
@ -116,12 +162,78 @@ class AIAnalysisService {
static let shared = AIAnalysisService() static let shared = AIAnalysisService()
private let client = APIClient.shared private let client = APIClient.shared
func analyze(text: String, type: String = "weakness", context: AIAnalysisRequest.AIAnalysisContext? = nil) async throws -> AIAnalysisResponse { func analyze(questionText: String, knowledgeItemContent: String, userAnswer: String) async throws -> AIAnalysisResult {
let body = AIAnalysisRequest(text: text, type: type, context: context) let body = AIAnalysisRequest(
questionText: questionText,
knowledgeItemContent: knowledgeItemContent,
userAnswer: userAnswer,
text: nil,
type: nil
)
return try await client.request("/ai-analysis", method: "POST", body: body) return try await client.request("/ai-analysis", method: "POST", body: body)
} }
} }
// MARK: - Learning Sessions
@MainActor
class LearningSessionService {
static let shared = LearningSessionService()
private let client = APIClient.shared
func list(page: Int = 1, limit: Int = 20) async throws -> [LearningSession] {
return try await client.request("/learning-sessions", queryItems: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "limit", value: String(limit)),
])
}
func start(knowledgeBaseId: String? = nil, knowledgeItemId: String? = nil, mode: String? = nil) async throws -> LearningSession {
let body = CreateLearningSessionRequest(
knowledgeBaseId: knowledgeBaseId,
knowledgeItemId: knowledgeItemId,
mode: mode
)
return try await client.request("/learning-sessions", method: "POST", body: body)
}
func end(id: String) async throws -> LearningSession {
return try await client.request("/learning-sessions/\(id)/end", method: "POST")
}
}
// MARK: - Review
@MainActor
class ReviewService {
static let shared = ReviewService()
private let client = APIClient.shared
func dueCards() async throws -> [ReviewCard] {
return try await client.request("/reviews/due")
}
func submit(id: String, rating: String, responseText: String? = nil) async throws -> GenericSuccessResponse {
let body = SubmitReviewRequest(rating: rating, responseText: responseText)
return try await client.request("/reviews/\(id)/submit", method: "POST", body: body)
}
}
// MARK: - Focus Items
@MainActor
class FocusItemService {
static let shared = FocusItemService()
private let client = APIClient.shared
func list(page: Int = 1, limit: Int = 20) async throws -> [FocusItem] {
return try await client.request("/focus-items", queryItems: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "limit", value: String(limit)),
])
}
}
// MARK: - Activity & Stats // MARK: - Activity & Stats
@MainActor @MainActor
@ -129,32 +241,12 @@ class ActivityService {
static let shared = ActivityService() static let shared = ActivityService()
private let client = APIClient.shared private let client = APIClient.shared
func summary() async throws -> ActivitySummaryResponse { func summary() async throws -> ActivitySummary {
return try await client.request("/activity/summary") return try await client.request("/activity/summary")
} }
}
// MARK: - Reviews func heatmap() async throws -> [String: Int] {
return try await client.request("/activity/heatmap")
@MainActor
class ReviewService {
static let shared = ReviewService()
private let client = APIClient.shared
func due() async throws -> ReviewListResponse {
return try await client.request("/reviews/due")
}
}
// MARK: - Focus Items / Weak Points
@MainActor
class FocusItemService {
static let shared = FocusItemService()
private let client = APIClient.shared
func list() async throws -> FocusItemListResponse {
return try await client.request("/focus-items")
} }
} }
@ -165,8 +257,27 @@ class FeedbackService {
static let shared = FeedbackService() static let shared = FeedbackService()
private let client = APIClient.shared private let client = APIClient.shared
func submit(type: String = "general", content: String, contact: String? = nil) async throws -> FeedbackResponse { func submit(category: String = "general", content: String, email: String? = nil) async throws -> FeedbackData {
let body = FeedbackCreateRequest(type: type, content: content, contact: contact) let body = FeedbackCreateRequest(category: category, content: content, email: email)
return try await client.request("/feedback", method: "POST", body: body) return try await client.request("/feedback", method: "POST", body: body)
} }
} }
// MARK: - Notifications
@MainActor
class NotificationService {
static let shared = NotificationService()
private let client = APIClient.shared
func list(page: Int = 1, limit: Int = 20) async throws -> [NotificationItem] {
return try await client.request("/notifications", queryItems: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "limit", value: String(limit)),
])
}
func markRead(id: String) async throws -> NotificationItem {
return try await client.request("/notifications/\(id)/read", method: "PATCH")
}
}

View File

@ -0,0 +1,24 @@
import Combine
import Foundation
@MainActor
class AIAnalysisViewModel: ObservableObject {
@Published var analysisResult: AIAnalysisResult?
@Published var isAnalyzing = false
@Published var errorMessage: String?
func requestAnalysis(questionText: String, knowledgeItemContent: String, userAnswer: String) async {
isAnalyzing = true
errorMessage = nil
do {
analysisResult = try await AIAnalysisService.shared.analyze(
questionText: questionText,
knowledgeItemContent: knowledgeItemContent,
userAnswer: userAnswer
)
} catch {
errorMessage = "AI 分析失败"
}
isAnalyzing = false
}
}

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import SwiftUI import SwiftUI
struct ActiveRecallView: View { struct ActiveRecallView: View {
@StateObject private var viewModel = ActiveRecallViewModel()
let questions: [RecallQuestion] = [ let questions: [RecallQuestion] = [
.init(id: "1", question: "请解释贝叶斯定理的核心思想,并写出公式", source: "机器学习 · 概率论", isVoice: false), .init(id: "1", question: "请解释贝叶斯定理的核心思想,并写出公式", source: "机器学习 · 概率论", isVoice: false),
.init(id: "2", question: "请用自己的话解释梯度下降算法的工作原理", source: "机器学习 · 优化算法", isVoice: false), .init(id: "2", question: "请用自己的话解释梯度下降算法的工作原理", source: "机器学习 · 优化算法", isVoice: false),
@ -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()
} }
} }
} }

View File

@ -0,0 +1,51 @@
import Combine
import Foundation
@MainActor
class ActiveRecallViewModel: ObservableObject {
@Published var questions: [ActiveRecallQuestion] = []
@Published var currentIndex = 0
@Published var isSubmitting = false
@Published var errorMessage: String?
@Published var lastResult: ActiveRecallAnswer?
var currentQuestion: ActiveRecallQuestion? {
guard currentIndex < questions.count else { return nil }
return questions[currentIndex]
}
func loadQuestions() async {
isSubmitting = false
errorMessage = nil
do {
questions = try await ActiveRecallService.shared.questions()
currentIndex = 0
} catch {
errorMessage = "加载问题失败"
}
}
func submitAnswer(text: String) async {
guard let question = currentQuestion else { return }
isSubmitting = true
errorMessage = nil
do {
lastResult = try await ActiveRecallService.shared.submit(
questionId: question.id, answerText: text
)
currentIndex += 1
} catch {
errorMessage = "提交回答失败"
}
isSubmitting = false
}
var isComplete: Bool {
currentIndex >= questions.count
}
var progress: Double {
guard !questions.isEmpty else { return 0 }
return Double(currentIndex) / Double(questions.count)
}
}

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import SwiftUI import SwiftUI
struct AnalysisHomeView: View { struct AnalysisHomeView: View {
@StateObject private var viewModel = ActivityViewModel()
var body: some View { var body: some View {
ZStack { ZStack {
Color.zxBg0.ignoresSafeArea() Color.zxBg0.ignoresSafeArea()
@ -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() }
} }
} }

View File

@ -5,6 +5,7 @@
import SwiftUI import SwiftUI
struct LibraryHomeView: View { struct LibraryHomeView: View {
@StateObject private var viewModel = LibraryViewModel()
@State private var s = "" @State private var s = ""
var body: some View { var body: some View {
ZStack { ZXGradient.page.ignoresSafeArea() ZStack { ZXGradient.page.ignoresSafeArea()
@ -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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,48 @@
import Combine
import Foundation
@MainActor
class ReviewViewModel: ObservableObject {
@Published var dueCards: [ReviewCard] = []
@Published var currentCardIndex = 0
@Published var isLoading = false
@Published var errorMessage: String?
var currentCard: ReviewCard? {
guard currentCardIndex < dueCards.count else { return nil }
return dueCards[currentCardIndex]
}
var isComplete: Bool {
currentCardIndex >= dueCards.count
}
var progress: Double {
guard !dueCards.isEmpty else { return 0 }
return Double(currentCardIndex) / Double(dueCards.count)
}
func loadDueCards() async {
isLoading = true
errorMessage = nil
do {
dueCards = try await ReviewService.shared.dueCards()
currentCardIndex = 0
} catch {
errorMessage = "加载复习卡片失败"
}
isLoading = false
}
func submitRating(_ rating: String, responseText: String? = nil) async {
guard let card = currentCard else { return }
do {
_ = try await ReviewService.shared.submit(
id: card.id, rating: rating, responseText: responseText
)
currentCardIndex += 1
} catch {
errorMessage = "提交复习结果失败"
}
}
}

View File

@ -1,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 ? "已完成" : "双击开始学习") }
} }

View File

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

View File

@ -0,0 +1,42 @@
import Combine
import Foundation
@MainActor
class StudyViewModel: ObservableObject {
@Published var sessions: [LearningSession] = []
@Published var currentSession: LearningSession?
@Published var isLoading = false
@Published var errorMessage: String?
func loadSessions() async {
isLoading = true
errorMessage = nil
do {
sessions = try await LearningSessionService.shared.list()
} catch {
errorMessage = "加载学习会话失败"
}
isLoading = false
}
func startSession(knowledgeBaseId: String? = nil, mode: String? = nil) async {
do {
currentSession = try await LearningSessionService.shared.start(
knowledgeBaseId: knowledgeBaseId, mode: mode
)
sessions.insert(currentSession!, at: 0)
} catch {
errorMessage = "开始学习会话失败"
}
}
func endCurrentSession() async {
guard let session = currentSession else { return }
do {
_ = try await LearningSessionService.shared.end(id: session.id)
currentSession = nil
} catch {
errorMessage = "结束学习会话失败"
}
}
}