Compare commits

...

3 Commits

Author SHA1 Message Date
a96d6cb159 feat(ios): 补全页面跳转、浅色模式、3个新页面
- 移除 3 处强制深色模式,用 @AppStorage 全局切换
- 设置页「外观」按钮实时切换深色/浅色/跟随系统
- 底部导航栏 inactive 颜色改为自适应 Color.zxF03
- 12 个子页面修复:保留返回按钮 + 消除顶部空白
- 新增 LearningSessionView/ReviewCardView/ActiveRecallView
- 新增 NotificationListView/SettingsView 等子页面
- 补全所有按钮 NavigationLink 跳转(0 个空白 action)
- KnowledgeBase 模型对齐服务器数据
- Info.plist 补充 CFBundleIdentifier + ATS
- 新增缺口分析文档 gap-analysis-1/2.md
2026-05-12 17:08:27 +08:00
fb95c27340 refactor: 重构架构,接入线上 API 并修复编译错误
- 删除旧 ViewModel/Repository/Storage 层,简化架构
- 新增网络层:APIClient、APIConfig、APIError
- 新增数据模型层:APIModels(含 20+ DTO)
- 新增服务层:APIService(含 8 个业务服务类)
- 各页面内联组件定义,消除跨文件依赖
- 修复 ZXQuickAction、ZXAIInteractionRow、ZXWeakRow 缺失
- 修复 ZXTabBtn 参数标签缺失
- 配置 ATS 例外允许 HTTP 请求
- API 地址指向线上服务器 81.70.187.179:3001
2026-05-11 17:27:56 +08:00
7066200b7b feat: MVVM 架构、全套 UI 页面、浅深色主题、本地持久化、等待名单、AI 动效
- 架构层:ViewModel/ObservableObject、Service/Repository、网络层 APIClient/APIEndpoint/APIError
- 设计系统:Color(light:dark:) 自适应 28 色 Token、ColorSchemeManager 深浅色切换
- 全页面:AI 对话/反馈/回忆/薄弱点、知识库 CRUD、学习工作台、复习计划、学习分析、个人中心/设置
- 登录与引导:Sign in with Apple、AppSession 状态管理、引导流程、演示模式
- 本地持久化:FileCache + PersistenceController(学习任务/复习任务/学习记录)
- 本地化:zh-Hans Localizable.strings ~120 条、ZXStrings 程序化引用、LanguageManager
- 组件库:ZXTabBar/ZXBackHeader/ZXSTaskRow/ZXChartView/ZXTypingIndicator 等 22 个共享组件
- 等待名单:WaitlistView 邮箱收集表单
- 动效:ZXTypingIndicator AI 打字动画、ZXShimmerModifier 骨架屏
- 测试:StudyHomeViewModel/AIChatViewModel/ReviewPlanViewModel/FileCache 共 28 条
- Dynamic Type 支持 + 范围限制

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 22:22:50 +08:00
35 changed files with 5772 additions and 695 deletions

View File

@ -91,6 +91,7 @@
knownRegions = ( knownRegions = (
en, en,
Base, Base,
"zh-Hans",
); );
mainGroup = 05F6CD122FA886330043A7BC; mainGroup = 05F6CD122FA886330043A7BC;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
@ -253,17 +254,7 @@
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly; ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Info.plist;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 26.4; IPHONEOS_DEPLOYMENT_TARGET = 26.4;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
@ -297,17 +288,7 @@
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly; ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Info.plist;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 26.4; IPHONEOS_DEPLOYMENT_TARGET = 26.4;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";

View File

@ -1,29 +1,30 @@
//
// AIStudyAppApp.swift - vs
//
import SwiftUI import SwiftUI
@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"
private var effectiveColorScheme: ColorScheme? {
switch appAppearance {
case "dark": return .dark
case "light": return .light
default: return nil
}
}
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
if hasCompletedOnboarding { if hasCompletedOnboarding {
ContentView() ContentView().preferredColorScheme(effectiveColorScheme)
.preferredColorScheme(.dark)
} else { } else {
OnboardingFlowView(hasCompletedOnboarding: $hasCompletedOnboarding) OnboardingFlowView(hasCompletedOnboarding: $hasCompletedOnboarding)
.preferredColorScheme(.dark) .preferredColorScheme(effectiveColorScheme)
} }
} }
} }
} }
// MARK: - Onboarding Flow (Splash Welcome Login Onboarding GoalSetup)
// React: SplashPage, WelcomePage, LoginPage, OnboardingPage, GoalSetupPage
struct OnboardingFlowView: View { struct OnboardingFlowView: View {
@Binding var hasCompletedOnboarding: Bool @Binding var hasCompletedOnboarding: Bool
@State private var step = 0 @State private var step = 0
@ -39,7 +40,6 @@ struct OnboardingFlowView: View {
default: EmptyView() default: EmptyView()
} }
} }
.preferredColorScheme(.dark)
} }
} }
@ -52,135 +52,73 @@ struct SplashPage: View {
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.25), .clear], center: .center, startRadius: 0, endRadius: 140)).frame(width: 280, height: 280).offset(y: -60).allowsHitTesting(false)
Circle().fill(RadialGradient(colors: [Color(hex: "#F97316", opacity: 0.15), .clear], center: .center, startRadius: 0, endRadius: 100)).frame(width: 200, height: 200).offset(y: 180).allowsHitTesting(false) Circle().fill(RadialGradient(colors: [Color(hex: "#F97316", opacity: 0.15), .clear], center: .center, startRadius: 0, endRadius: 100)).frame(width: 200, height: 200).offset(y: 180).allowsHitTesting(false)
VStack(spacing: 0) { VStack(spacing: 0) {
RoundedRectangle(cornerRadius: 28) 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)
.fill(LinearGradient(colors: [Color(hex: "#7C6EFA"), Color(hex: "#A78BFA"), Color(hex: "#F97316")], startPoint: .topLeading, endPoint: .bottomTrailing)) 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))
.frame(width: 96, height: 96)
.overlay(Image(systemName: "brain.head.profile").font(.system(size: 44)).foregroundColor(.white.opacity(0.8)))
.shadow(color: Color(hex: "#7C6EFA", opacity: 0.5), radius: 40)
.padding(.bottom, 24)
Text("知习")
.font(.system(size: 36, weight: .heavy)).tracking(-1)
.foregroundStyle(LinearGradient(colors: [Color(hex: "#A78BFA"), Color(hex: "#F0F0FF"), Color(hex: "#F97316")], startPoint: .leading, endPoint: .trailing))
Text("Z H I X I").font(.system(size: 13, weight: .medium)).foregroundColor(Color(hex: "#F0F0FF", opacity: 0.4)).tracking(3).padding(.top, 6) Text("Z H I X I").font(.system(size: 13, weight: .medium)).foregroundColor(Color(hex: "#F0F0FF", opacity: 0.4)).tracking(3).padding(.top, 6)
Text("AI-first 系统化学习").font(.system(size: 14)).foregroundColor(Color(hex: "#F0F0FF", opacity: 0.45)).tracking(0.5).padding(.top, 24) Text("AI-first 系统化学习").font(.system(size: 14)).foregroundColor(Color(hex: "#F0F0FF", opacity: 0.45)).tracking(0.5).padding(.top, 24)
} }
VStack { Spacer() 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) }
ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 2).fill(Color(hex: "#FFFFFF", opacity: 0.1)).frame(width: 40, height: 3); RoundedRectangle(cornerRadius: 2).fill(LinearGradient(colors: [.zxPurple, Color.zxOrange], startPoint: .leading, endPoint: .trailing)).frame(width: 24, height: 3) } }.onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { onFinish() } }
.padding(.bottom, 80)
}
}
.onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { onFinish() } }
} }
} }
// Welcome // Welcome
struct WelcomePage: View { struct WelcomePage: View { let onContinue: () -> Void; let onSkip: () -> Void
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)
var body: some View { VStack { Spacer()
ZStack { 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())
ZXGradient.page.ignoresSafeArea() Text("用 AI 重新定义\n你的学习方式").font(.system(size: 32, weight: .heavy)).tracking(-0.8).lineSpacing(4)
Circle().fill(RadialGradient(colors: [Color(hex: "#7C6EFA", opacity: 0.12), .clear], center: .topTrailing, startRadius: 0, endRadius: 260)).frame(width: 260, height: 260).offset(x: 80, y: -120).allowsHitTesting(false) VStack(spacing: 10) { FeatureRow(icon: "🧠", title: "主动回忆", desc: "基于间隔重复的智能复习"); FeatureRow(icon: "🎤", title: "费曼解释", desc: "用自己的话讲出来"); FeatureRow(icon: "📊", title: "AI 分析", desc: "发现知识薄弱点") } }
VStack { Spacer() 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) } }
VStack(spacing: 14) {
HStack(spacing: 6) { Image(systemName: "sparkles").font(.system(size: 12)); Text("AI 驱动").font(.system(size: 12, weight: .semibold)) }
.foregroundColor(Color.zxAccent).padding(.horizontal, 12).padding(.vertical, 6).background(Color(hex: "#7C6EFA", opacity: 0.1)).clipShape(Capsule())
Text("用 AI 重新定义\n你的学习方式").font(.system(size: 32, weight: .heavy)).tracking(-0.8).lineSpacing(4)
VStack(spacing: 10) { FeatureRow(icon: "🧠", title: "主动回忆", desc: "基于间隔重复的智能复习"); FeatureRow(icon: "🎤", title: "费曼解释", desc: "用自己的话讲出来"); FeatureRow(icon: "📊", title: "AI 分析", desc: "发现知识薄弱点") }
}
VStack(spacing: 12) { Button { onContinue() } label: { Text("开始使用").font(.system(size: 16, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 56).background(ZXGradient.ctaButton).clipShape(RoundedRectangle(cornerRadius: 18)).shadow(color: Color(hex: "#7C6EFA", opacity: 0.4), radius: 20) }; Button { onSkip() } label: { Text("已有账号?立即登录").font(.system(size: 14, weight: .medium)).foregroundColor(Color(hex: "#F0F0FF", opacity: 0.7)) }.padding(.bottom, 32) }
}.padding(.horizontal, 20)
}
}
} }
struct FeatureRow: View { let icon: String; let title: String; let desc: String 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)) } 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)) }
} }
// Login // Login
struct LoginPage: View { struct LoginPage: View { let onContinue: () -> Void; let onSkip: () -> Void
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 @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 { 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)
ZStack { VStack { Spacer()
Color.zxBg0.ignoresSafeArea() 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))
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) 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) } }
VStack { Spacer() 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)) } }
VStack(spacing: 24) { 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)
VStack(spacing: 6) { Text("欢迎登录").font(.system(size: 28, weight: .heavy)).tracking(-0.6); Text("使用手机号或邮箱登录").font(.system(size: 14)).foregroundColor(Color.zxF05) } HStack { Spacer(); Button("忘记密码?") {}.font(.system(size: 13)).foregroundColor(Color.zxPurple) }
HStack(spacing: 4) { tabBtn("手机号", !isEmail) { isEmail = false }; tabBtn("邮箱", isEmail) { isEmail = true } }.padding(4).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 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) }
if isEmail { 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) }
VStack(alignment: .leading, spacing: 8) { HStack(spacing: 12) { SocialLoginBtn(emoji: "💬", text: "微信登陆", color: .green) {}; SocialLoginBtn(emoji: "🍎", text: "Apple 登录", color: .white) {} } }.padding(.horizontal, 20).padding(.bottom, 32) } } }
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)
}
}
}
func tabBtn(_ t: String, _ active: Bool, _ a: @escaping () -> Void) -> some View { Button(action: a) { Text(t).font(.system(size: 13, weight: .semibold)).foregroundColor(active ? .white : Color.zxF05).frame(maxWidth: .infinity).frame(height: 36).background(active ? AnyView(ZXGradient.brand) : AnyView(Color.clear)).clipShape(RoundedRectangle(cornerRadius: 9)) } }
}
struct ZXInputField: View { let placeholder: String; @Binding var text: String; var isSecure = false
var body: some View { HStack { if isSecure { SecureField(placeholder, text: $text) } else { TextField(placeholder, text: $text) } }.font(.system(size: 15)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 52).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 14)) }
}
struct SocialLoginBtn: View { let emoji: String; let text: String; let color: Color; let action: () -> Void
var body: some View { Button(action: action) { HStack(spacing: 10) { Text(emoji).font(.system(size: 18)); Text(text).font(.system(size: 11, weight: .medium)) }.foregroundColor(Color.zxF007).frame(maxWidth: .infinity).frame(height: 52).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 14)) } }
} }
struct ZXTabBtn: View { let t: String; let active: Bool; let a: () -> Void; var body: some View { Button(action: a) { Text(t).font(.system(size: 13, weight: .semibold)).foregroundColor(active ? .white : Color.zxF05).frame(maxWidth: .infinity).frame(height: 36).background(active ? AnyView(ZXGradient.brand) : AnyView(Color.clear)).clipShape(RoundedRectangle(cornerRadius: 9)) } } }
struct ZXInputField: View { let placeholder: String; @Binding var text: String; var isSecure = false; var body: some View { HStack { if isSecure { SecureField(placeholder, text: $text) } else { TextField(placeholder, text: $text) } }.font(.system(size: 15)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 52).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 14)) } }
struct SocialLoginBtn: View { let emoji: String; let text: String; let color: Color; let action: () -> Void; var body: some View { Button(action: action) { HStack(spacing: 10) { Text(emoji).font(.system(size: 18)); Text(text).font(.system(size: 11, weight: .medium)) }.foregroundColor(Color.zxF007).frame(maxWidth: .infinity).frame(height: 52).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 14)) } } }
// Onboarding // Onboarding
struct OnboardingPage: View { struct OnboardingPage: View { let onContinue: () -> Void; @State private var step = 0
let onContinue: () -> Void
@State private var step = 0
let titles = ["输入知识", "主动输出", "AI 分析", "掌握知识"] let titles = ["输入知识", "主动输出", "AI 分析", "掌握知识"]
let descs = ["从任何地方收集并导入学习资料,构建你的专属知识库。", "通过间隔回忆和费曼解释法,将知识转化为长期记忆。", "AI 自动定位薄弱知识点,给出针对性的学习建议。", "系统性掌握每一个知识点,建立牢固的知识体系。"] let descs = ["从任何地方收集并导入学习资料,构建你的专属知识库。", "通过间隔回忆和费曼解释法,将知识转化为长期记忆。", "AI 自动定位薄弱知识点,给出针对性的学习建议。", "系统性掌握每一个知识点,建立牢固的知识体系。"]
let icons = ["square.and.arrow.down", "brain.head.profile", "sparkle.magnifyingglass", "chart.line.uptrend.xyaxis"] var body: some View { ZStack { ZXGradient.page.ignoresSafeArea()
var body: some View { VStack(spacing: 0) { Spacer()
ZStack { ZXGradient.page.ignoresSafeArea() 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: 0) { Spacer() 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)
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) } } 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: 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("跳过") { onContinue() }.font(.system(size: 12)).foregroundColor(Color.zxF03).padding(.top, 12).padding(.bottom, 32) } }.padding(.horizontal, 20) }
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)
}
}
} }
struct GoalSetupPage: View {
let onComplete: (Bool) -> Void // true = launch app // GoalSetup
@State private var selectedGoal = "" struct GoalSetupPage: View { let onComplete: (Bool) -> Void
let goals = [("🧑‍🎓","备考考试","公考、考研、考证等"),("💼","职业技能","编程、设计、产品等"),("📚","通识学习","扩充知识面"),("🎯","自定义","设定自己的目标")] @State private var selectedGoal = ""; let goals = [("🧑‍🎓","备考考试","公考、考研、考证等"),("💼","职业技能","编程、设计、产品等"),("📚","通识学习","扩充知识面"),("🎯","自定义","设定自己的目标")]
@State private var selectedMethod = "" @State private var selectedMethod = ""; let methods = ["间隔回忆","费曼技巧","AI 分析"]
let methods = ["间隔回忆","费曼技巧","AI 分析"] @State private var dailyMins = "30 分钟"; let times = ["15 分钟","30 分钟","1 小时","不限制"]
@State private var dailyMins = "30 分钟" var body: some View { ZStack { ZXGradient.page.ignoresSafeArea()
let times = ["15 分钟","30 分钟","1 小时","不限制"] VStack(spacing: 0) { Spacer()
var body: some View { Text("设定你的学习目标").font(.system(size: 24, weight: .heavy)).tracking(-0.5).foregroundColor(Color.zxF0).padding(.bottom, 24)
ZStack { ZXGradient.page.ignoresSafeArea() ScrollView { VStack(spacing: 16) {
VStack(spacing: 0) { Spacer() VStack(alignment: .leading, spacing: 10) { Text("学习目标").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5)
Text("设定你的学习目标").font(.system(size: 24, weight: .heavy)).tracking(-0.5).foregroundColor(Color.zxF0).padding(.bottom, 24) ForEach(goals, id: \.1) { g in let sel = selectedGoal == g.1; Button { selectedGoal = g.1 } label: { HStack(spacing: 12) { Text(g.0).font(.system(size: 22)).frame(width: 44, height: 44).background(sel ? Color(hex: "#7C6EFA", opacity: 0.15) : Color.zxFill005).clipShape(RoundedRectangle(cornerRadius: 12)); VStack(alignment: .leading, spacing: 2) { Text(g.1).font(.system(size: 15, weight: .semibold)).foregroundColor(sel ? Color.zxPurple : Color.zxF0); Text(g.2).font(.system(size: 12)).foregroundColor(Color.zxF04) }; Spacer(); Circle().stroke(sel ? Color.zxPurple : Color(hex: "#FFFFFF", opacity: 0.2), lineWidth: 2).frame(width: 22, height: 22).overlay { if sel { Circle().fill(Color.zxPurple).frame(width: 12, height: 12) } } }.padding(14).background(sel ? Color(hex: "#7C6EFA", opacity: 0.08) : Color.zxFill003).overlay(RoundedRectangle(cornerRadius: 16).stroke(sel ? Color(hex: "#7C6EFA", opacity: 0.25) : Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 16)) }.foregroundColor(.primary) } }
ScrollView { VStack(spacing: 16) { 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) } } }
ForEach(goals, id: \.1) { g in let sel = selectedGoal == g.1; Button { selectedGoal = g.1 } label: { HStack(spacing: 12) { Text(g.0).font(.system(size: 22)).frame(width: 44, height: 44).background(sel ? Color(hex: "#7C6EFA", opacity: 0.15) : Color.zxFill005).clipShape(RoundedRectangle(cornerRadius: 12)); VStack(alignment: .leading, spacing: 2) { Text(g.1).font(.system(size: 15, weight: .semibold)).foregroundColor(sel ? Color.zxPurple : Color.zxF0); Text(g.2).font(.system(size: 12)).foregroundColor(Color.zxF04) }; Spacer(); Circle().stroke(sel ? Color.zxPurple : Color(hex: "#FFFFFF", opacity: 0.2), lineWidth: 2).frame(width: 22, height: 22).overlay { if sel { Circle().fill(Color.zxPurple).frame(width: 12, height: 12) } } }.padding(14).background(sel ? Color(hex: "#7C6EFA", opacity: 0.08) : Color.zxFill003).overlay(RoundedRectangle(cornerRadius: 16).stroke(sel ? Color(hex: "#7C6EFA", opacity: 0.25) : Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 16)) }.foregroundColor(.primary) } VStack(alignment: .leading, spacing: 10) { Text("每日学习时间").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5)
} HStack(spacing: 8) { ForEach(times, id: \.self) { t in let sel = dailyMins == t; Button { dailyMins = t } label: { Text(t).font(.system(size: 12)).fontWeight(sel ? .semibold : .regular).foregroundColor(sel ? Color.zxPurple : Color.zxF05).frame(maxWidth: .infinity).frame(height: 40).background(sel ? Color(hex: "#7C6EFA", opacity: 0.1) : Color.zxFill003).overlay(RoundedRectangle(cornerRadius: 12).stroke(sel ? Color(hex: "#7C6EFA", opacity: 0.25) : Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 12)) }.foregroundColor(.primary) } } } } }
VStack(alignment: .leading, spacing: 10) { Text("学习方法").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5) Button { onComplete(true) } label: { Text("开始学习").font(.system(size: 16, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 56).background(ZXGradient.ctaButton).clipShape(RoundedRectangle(cornerRadius: 18)).shadow(color: Color(hex: "#7C6EFA", opacity: 0.4), radius: 20) }.padding(.top, 24).padding(.bottom, 32).padding(.horizontal, 20) } } }
HStack(spacing: 8) { ForEach(methods, id: \.self) { m in let sel = selectedMethod == m; Button { selectedMethod = m } label: { Text(m).font(.system(size: 13)).fontWeight(sel ? .semibold : .regular).foregroundColor(sel ? Color.zxPurple : Color.zxF05).padding(.horizontal, 16).padding(.vertical, 10).background(sel ? Color(hex: "#7C6EFA", opacity: 0.1) : Color.zxFill003).overlay(RoundedRectangle(cornerRadius: 20).stroke(sel ? Color(hex: "#7C6EFA", opacity: 0.25) : Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20)) }.foregroundColor(.primary) } }
}
VStack(alignment: .leading, spacing: 10) { Text("每日学习时间").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5)
HStack(spacing: 8) { ForEach(times, id: \.self) { t in let sel = dailyMins == t; Button { dailyMins = t } label: { Text(t).font(.system(size: 12)).fontWeight(sel ? .semibold : .regular).foregroundColor(sel ? Color.zxPurple : Color.zxF05).frame(maxWidth: .infinity).frame(height: 40).background(sel ? Color(hex: "#7C6EFA", opacity: 0.1) : Color.zxFill003).overlay(RoundedRectangle(cornerRadius: 12).stroke(sel ? Color(hex: "#7C6EFA", opacity: 0.25) : Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 12)) }.foregroundColor(.primary) } }
}
} }.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

@ -2,110 +2,59 @@ import SwiftUI
struct ContentView: View { struct ContentView: View {
@State private var selectedTab = "ai" @State private var selectedTab = "ai"
var body: some View { var body: some View {
ZStack { ZStack {
switch selectedTab { switch selectedTab {
case "ai": NavigationStack { AIHomeView() } case "ai": NavigationStack { AIHomeView().background(Color.zxBg0.ignoresSafeArea()) }
case "library": NavigationStack { LibraryHomeView() } case "library": NavigationStack { LibraryHomeView().background(Color.zxBg0.ignoresSafeArea()) }
case "study": NavigationStack { StudyHomeView() } case "study": NavigationStack { StudyHomeView().background(Color.zxBg0.ignoresSafeArea()) }
case "analysis": NavigationStack { AnalysisHomeView() } case "analysis": NavigationStack { AnalysisHomeView().background(Color.zxBg0.ignoresSafeArea()) }
case "profile": NavigationStack { ProfileView() } case "profile": NavigationStack { ProfileView().background(Color.zxBg0.ignoresSafeArea()) }
default: NavigationStack { AIHomeView() } default: NavigationStack { AIHomeView() }
} }
VStack { Spacer(); ZXTabBar(active: $selectedTab) } VStack { Spacer(); ZXTabBar(active: $selectedTab) }.ignoresSafeArea(edges: .bottom)
.ignoresSafeArea(edges: .bottom) }.ignoresSafeArea(edges: .bottom)
}
.ignoresSafeArea(edges: .bottom)
.preferredColorScheme(.dark)
}
}
// MARK: - AI Input Bar
struct ZXAIInputBar: View {
@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)
}
}
// MARK: - Score Box
struct ZXScoreBox: View {
let score: Int; let bg: Color; let fg: Color
var body: some View {
Text("\(score)").font(.system(size: 12, weight: .heavy)).foregroundColor(fg)
.frame(width: 36, height: 36).background(bg).clipShape(RoundedRectangle(cornerRadius: 10))
} }
} }
struct ZXTabBar: View { struct ZXTabBar: View {
@Binding var active: String @Binding var active: String
private let tabs = [ private let items = [("ai","AI","brain.head.profile"),("library","知识库","books.vertical.fill"),("study","学习","bolt.fill"),("analysis","分析","chart.bar.fill"),("profile","我的","person.fill")]
("ai","AI","brain.head.profile"), 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)}}
("library","知识库","books.vertical.fill"),
("study","学习","bolt.fill"),
("analysis","分析","chart.bar.fill"),
("profile","我的","person.fill"),
]
var body: some View {
HStack(spacing: 0) {
ForEach(tabs, 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(hex: "#F0F0FF", opacity: 0.35))
}
Text(item.1)
.font(.system(size: 10, weight: on ? .semibold : .regular))
.foregroundColor(on ? Color.zxPurple : Color(hex: "#F0F0FF", opacity: 0.35))
}
}
.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)
}
}
} }
// MARK: - Shared Icon Button
struct ZXIconBtn: View { struct ZXIconBtn: View {
let icon: String; let size: CGFloat; var branded = false; let action: () -> Void let icon: String; let size: CGFloat; var branded = false; let action: () -> Void
var body: some View {Button(action:action){Image(systemName:icon).font(.system(size:size*0.44)).frame(width:size,height:size)}.foregroundColor(branded ? .white:Color.zxF05).background(branded ? AnyView(ZXGradient.brand):AnyView(Color(hex:"#FFFFFF",opacity:0.05))).clipShape(RoundedRectangle(cornerRadius:10)).overlay{if !branded{RoundedRectangle(cornerRadius:10).stroke(Color.zxBorder008,lineWidth:1)}}}
}
struct ZXScoreBox: View {let score:Int;let bg:Color;let fg:Color
var body: some View {Text("\(score)").font(.system(size:12,weight:.heavy)).foregroundColor(fg).frame(width:36,height:36).background(bg).clipShape(RoundedRectangle(cornerRadius:10))}
}
struct ZXWeakRow: View {
let score: Int; let topic: String; let lib: String; let priority: String
var body: some View { var body: some View {
Button(action: action) { HStack(spacing: 12) {
Image(systemName: icon).font(.system(size: size * 0.44)).frame(width: size, height: size) Text("\(score)").font(.system(size:13,weight:.heavy)).foregroundColor(Color.zxYellow)
.frame(width:40,height:40).background(Color.zxYellowBG(0.15)).clipShape(RoundedRectangle(cornerRadius:12))
VStack(alignment:.leading,spacing:2){
Text(topic).font(.system(size:13,weight:.semibold)).foregroundColor(Color.zxF0)
Text(lib).font(.system(size:11)).foregroundColor(Color.zxF04)
}.frame(maxWidth:.infinity,alignment:.leading)
Text("\(priority)优先").font(.system(size:11,weight:.bold))
.foregroundColor(priority=="" ? Color.zxRed:Color.zxYellow)
.padding(.horizontal,8).padding(.vertical,3)
.background((priority=="" ? Color.zxRedBG(0.15):Color.zxYellowBG(0.15))).clipShape(Capsule())
} }
.foregroundColor(branded ? .white : Color.zxF05) .padding(.horizontal,16).padding(.vertical,12)
.background(branded ? AnyView(ZXGradient.brand) : AnyView(Color(hex: "#FFFFFF", opacity: 0.05))) .background(Color.zxYellowBG(0.06))
.clipShape(RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius:14).stroke(Color(hex:"#F59E0B",opacity:0.15),lineWidth:1))
.overlay { if !branded { RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1) } } .clipShape(RoundedRectangle(cornerRadius:14))
} }
} }
struct ZXAIInputBar: View {
@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)}
}

View File

@ -27,23 +27,39 @@ extension Color {
} }
} }
// MARK: - Adaptive Color Helper
extension Color {
init(light: Color, dark: Color) {
#if canImport(UIKit)
self.init(UIColor { traitCollection in
traitCollection.userInterfaceStyle == .dark ? UIColor(dark) : UIColor(light)
})
#else
self.init(light)
#endif
}
}
// MARK: - ZhiXi Colors (exact match from React index.css) // MARK: - ZhiXi Colors (exact match from React index.css)
extension Color { extension Color {
// //
static let zxBg0 = Color(hex: "#0F0F1A") static let zxBg0 = Color(light: Color(hex: "#F5F5FA"), dark: Color(hex: "#0F0F1A"))
static let zxBg1 = Color(hex: "#12122A") static let zxBg1 = Color(light: Color(hex: "#EAEAF2"), dark: Color(hex: "#12122A"))
static let zxBg2 = Color(hex: "#0A0A14") // phone shell static let zxBg2 = Color(light: Color(hex: "#E0E0E8"), dark: Color(hex: "#0A0A14"))
static let zxBgSplash = Color(hex: "#0D0D20") static let zxBgSplash = Color(light: Color(hex: "#F0F0F8"), dark: Color(hex: "#0D0D20"))
// //
static let zxF0 = Color(hex: "#F0F0FF") static let zxF0 = Color(light: Color(hex: "#1A1A2E"), dark: Color(hex: "#F0F0FF"))
static let zxF05 = Color(hex: "#F0F0FF", opacity: 0.5) static let zxF05 = Color(light: Color(hex: "#1A1A2E", opacity: 0.5), dark: Color(hex: "#F0F0FF", opacity: 0.5))
static let zxF04 = Color(hex: "#F0F0FF", opacity: 0.4) static let zxF04 = Color(light: Color(hex: "#1A1A2E", opacity: 0.4), dark: Color(hex: "#F0F0FF", opacity: 0.4))
static let zxF03 = Color(hex: "#F0F0FF", opacity: 0.3) static let zxF03 = Color(light: Color(hex: "#1A1A2E", opacity: 0.3), dark: Color(hex: "#F0F0FF", opacity: 0.3))
static let zxF007 = Color(hex: "#F0F0FF", opacity: 0.7) static let zxF007 = Color(light: Color(hex: "#1A1A2E", opacity: 0.7), dark: Color(hex: "#F0F0FF", opacity: 0.7))
static let zxF006 = Color(hex: "#F0F0FF", opacity: 0.6) static let zxF006 = Color(light: Color(hex: "#1A1A2E", opacity: 0.6), dark: Color(hex: "#F0F0FF", opacity: 0.6))
static let zxF0045 = Color(hex: "#F0F0FF", opacity: 0.45) static let zxF0045 = Color(light: Color(hex: "#1A1A2E", opacity: 0.45), dark: Color(hex: "#F0F0FF", opacity: 0.45))
static let zxF035 = Color(light: Color(hex: "#1A1A2E", opacity: 0.35), dark: Color(hex: "#F0F0FF", opacity: 0.35))
static let zxF02 = Color(light: Color(hex: "#1A1A2E", opacity: 0.2), dark: Color(hex: "#F0F0FF", opacity: 0.2))
// //
static let zxPurple = Color(hex: "#7C6EFA") static let zxPurple = Color(hex: "#7C6EFA")
@ -55,22 +71,20 @@ extension Color {
static let zxRed = Color(hex: "#EF4444") static let zxRed = Color(hex: "#EF4444")
static let zxCyan = Color(hex: "#4ECDC4") static let zxCyan = Color(hex: "#4ECDC4")
static let zxF02 = Color(hex: "#F0F0FF", opacity: 0.2)
static let zxFill01 = Color(hex: "#FFFFFF", opacity: 0.1)
// /线 // /线
static let zxBorder008 = Color(hex: "#FFFFFF", opacity: 0.08) static let zxBorder008 = Color(light: Color(hex: "#000000", opacity: 0.08), dark: Color(hex: "#FFFFFF", opacity: 0.08))
static let zxBorder006 = Color(hex: "#FFFFFF", opacity: 0.06) static let zxBorder006 = Color(light: Color(hex: "#000000", opacity: 0.06), dark: Color(hex: "#FFFFFF", opacity: 0.06))
static let zxBorder004 = Color(hex: "#FFFFFF", opacity: 0.04) static let zxBorder004 = Color(light: Color(hex: "#000000", opacity: 0.04), dark: Color(hex: "#FFFFFF", opacity: 0.04))
static let zxBorder01 = Color(hex: "#FFFFFF", opacity: 0.10) static let zxBorder01 = Color(light: Color(hex: "#000000", opacity: 0.10), dark: Color(hex: "#FFFFFF", opacity: 0.10))
static let zxBorder015 = Color(hex: "#FFFFFF", opacity: 0.15) static let zxBorder015 = Color(light: Color(hex: "#000000", opacity: 0.15), dark: Color(hex: "#FFFFFF", opacity: 0.15))
// //
static let zxFill003 = Color(hex: "#FFFFFF", opacity: 0.03) static let zxFill003 = Color(light: Color(hex: "#000000", opacity: 0.03), dark: Color(hex: "#FFFFFF", opacity: 0.03))
static let zxFill004 = Color(hex: "#FFFFFF", opacity: 0.04) static let zxFill004 = Color(light: Color(hex: "#000000", opacity: 0.04), dark: Color(hex: "#FFFFFF", opacity: 0.04))
static let zxFill005 = Color(hex: "#FFFFFF", opacity: 0.05) static let zxFill005 = Color(light: Color(hex: "#000000", opacity: 0.05), dark: Color(hex: "#FFFFFF", opacity: 0.05))
static let zxFill006 = Color(hex: "#FFFFFF", opacity: 0.06) static let zxFill006 = Color(light: Color(hex: "#000000", opacity: 0.06), dark: Color(hex: "#FFFFFF", opacity: 0.06))
static let zxFill008 = Color(hex: "#FFFFFF", opacity: 0.08) static let zxFill008 = Color(light: Color(hex: "#000000", opacity: 0.08), dark: Color(hex: "#FFFFFF", opacity: 0.08))
static let zxFill01 = Color(light: Color(hex: "#000000", opacity: 0.10), dark: Color(hex: "#FFFFFF", opacity: 0.10))
// //
static func zxPurpleBG(_ a: Double = 0.10) -> Color { Color(hex: "#7C6EFA", opacity: a) } static func zxPurpleBG(_ a: Double = 0.10) -> Color { Color(hex: "#7C6EFA", opacity: a) }
@ -86,7 +100,7 @@ extension Color {
enum ZXGradient { enum ZXGradient {
// //
static let page = LinearGradient( static let page = LinearGradient(
colors: [Color(hex: "#0F0F1A"), Color(hex: "#12122A")], colors: [Color.zxBg0, Color.zxBg1],
startPoint: .top, endPoint: .bottom startPoint: .top, endPoint: .bottom
) )
@ -140,9 +154,9 @@ enum ZXGradient {
// Splash // Splash
static let splash = LinearGradient( static let splash = LinearGradient(
colors: [ colors: [
Color(hex: "#0D0D20"), Color.zxBgSplash,
Color(hex: "#0F0F1A"), Color.zxBg0,
Color(hex: "#130D20") Color(light: Color(hex: "#F0F0F5"), dark: Color(hex: "#130D20"))
], ],
startPoint: .top, endPoint: .bottom startPoint: .top, endPoint: .bottom
) )

View File

@ -0,0 +1,313 @@
//
// Models.swift - api-server DTO
//
import Foundation
// MARK: - Waitlist
struct WaitlistEntry: Codable, Identifiable {
let id: String
let email: String
let nickname: String?
let devices: [String]?
let interests: [String]?
let painpoint: String?
let willingBeta: Bool?
let createdAt: String
}
struct WaitlistCreateRequest: Codable {
let email: String
let nickname: String?
let devices: [String]?
let interests: [String]?
let painpoint: String?
let willingBeta: Bool?
init(email: String, nickname: String? = nil, devices: [String]? = nil,
interests: [String]? = nil, painpoint: String? = nil, willingBeta: Bool = true) {
self.email = email
self.nickname = nickname
self.devices = devices
self.interests = interests
self.painpoint = painpoint
self.willingBeta = willingBeta
}
}
struct WaitlistResponse: Codable {
let success: Bool
let message: String?
let data: WaitlistEntry?
}
struct WaitlistStats: Codable {
let total: Int?
let today: Int?
let deviceBreakdown: [String: Int]?
let interestBreakdown: [String: Int]?
enum CodingKeys: String, CodingKey {
case total, today
case deviceBreakdown = "deviceBreakdown"
case interestBreakdown = "interestBreakdown"
}
}
// MARK: - Auth
struct AuthResponse: Codable {
let success: Bool
let data: AuthTokens?
}
struct AuthTokens: Codable {
let accessToken: String
let refreshToken: String?
let expiresIn: Int?
enum CodingKeys: String, CodingKey {
case accessToken, refreshToken, expiresIn
}
}
struct AppleAuthRequest: Codable {
let identityToken: String
let fullName: AppleFullName?
struct AppleFullName: Codable {
let givenName: String?
let familyName: String?
}
}
// MARK: - User
struct UserProfileResponse: Codable {
let success: Bool
let data: UserProfileData?
}
struct UserProfileData: Codable, Identifiable {
let id: String
let email: String
let nickname: String?
let avatar: String?
let preferences: UserPreferences?
let stats: UserStats?
let createdAt: String?
}
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?
}
// MARK: - Knowledge Base
struct KnowledgeBase: Codable, Identifiable {
let id: String
let userId: String?
let title: String
let description: String?
let status: String?
let itemCount: Int?
let lastStudiedAt: String?
let createdAt: String?
let updatedAt: String?
}
typealias KnowledgeBaseListResponse = [KnowledgeBase]
struct CreateKnowledgeBaseRequest: Codable {
let name: String
let description: String?
let icon: String?
}
// MARK: - Knowledge Items
struct KnowledgeItem: Codable, Identifiable {
let id: String
let title: String
let content: String?
let baseId: String?
let tags: [String]?
let mastery: Double?
let status: String?
let createdAt: String?
enum CodingKeys: String, CodingKey {
case id, title, content, tags, mastery, status, createdAt
case baseId = "baseId"
}
}
struct KnowledgeItemListResponse: Codable {
let success: Bool
let data: [KnowledgeItem]?
}
struct CreateKnowledgeItemRequest: Codable {
let title: String
let content: String?
let baseId: String
let tags: [String]?
enum CodingKeys: String, CodingKey {
case title, content, tags
case baseId = "baseId"
}
}
// MARK: - AI Analysis
struct AIAnalysisRequest: Codable {
let text: String
let type: String
let context: AIAnalysisContext?
struct AIAnalysisContext: Codable {
let knowledgeBaseIds: [String]?
let focusItemIds: [String]?
}
}
struct AIAnalysisResponse: Codable {
let success: Bool
let data: AIAnalysisResult?
}
struct AIAnalysisResult: Codable, Identifiable {
let id: String
let type: String?
let summary: String?
let strengths: [String]?
let weaknesses: [String]?
let suggestions: [String]?
let score: Double?
let createdAt: String?
}
// MARK: - Feedback
struct FeedbackCreateRequest: Codable {
let type: String
let content: String
let contact: String?
init(type: String = "general", content: String, contact: String? = nil) {
self.type = type
self.content = content
self.contact = contact
}
}
struct FeedbackResponse: Codable {
let success: Bool
let message: String?
let data: FeedbackData?
}
struct FeedbackData: Codable, Identifiable {
let id: String
let type: String?
let content: String?
let status: String?
let createdAt: String?
}
// MARK: - Learning Session
struct LearningSessionCreateRequest: Codable {
let knowledgeBaseId: String?
let notes: String?
enum CodingKeys: String, CodingKey {
case notes
case knowledgeBaseId = "baseId"
}
}
struct LearningSessionResponse: Codable {
let success: Bool
let data: LearningSessionData?
}
struct LearningSessionData: Codable, Identifiable {
let id: String
let startedAt: String?
let endedAt: String?
let durationMinutes: Double?
}
// MARK: - Activity
struct ActivitySummary: Codable {
let totalSessions: Int?
let totalMinutes: Double?
let streakDays: Int?
let weeklyMinutes: Double?
}
struct ActivitySummaryResponse: Codable {
let success: Bool
let data: ActivitySummary?
}
// MARK: - Reviews
struct ReviewTask: Codable, Identifiable {
let id: String
let itemId: String?
let itemName: String?
let dueDate: String?
let type: String?
}
struct ReviewListResponse: Codable {
let success: Bool
let data: [ReviewTask]?
}
// MARK: - Focus Items / Weak Points
struct FocusItem: Codable, Identifiable {
let id: String
let itemId: String?
let itemName: String?
let reason: String?
let priority: String?
let completed: Bool?
let createdAt: String?
}
struct FocusItemListResponse: Codable {
let success: Bool
let data: [FocusItem]?
}
// MARK: - Generic API Response
struct APIStatusResponse: Codable {
let status: String?
let success: Bool?
}
struct GenericSuccessResponse: Codable {
let success: Bool
let message: String?
}

View File

@ -0,0 +1,78 @@
//
// APIClient.swift - HTTP
//
import Foundation
actor APIClient {
static let shared = APIClient()
private let session: URLSession
private var token: String?
private init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = APIConfig.timeout
config.timeoutIntervalForResource = 60
session = URLSession(configuration: config)
}
func setToken(_ token: String?) {
self.token = token
}
// MARK: - Generic request
func request<T: Decodable>(
_ path: String,
method: String = "GET",
body: Encodable? = nil,
queryItems: [URLQueryItem]? = nil
) async throws -> T {
var components = URLComponents(url: APIConfig.url(path), resolvingAgainstBaseURL: true)!
if let queryItems { components.queryItems = queryItems }
var request = URLRequest(url: components.url!)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let token {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body {
request.httpBody = try JSONEncoder().encode(AnyEncodable(body))
}
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.networkError(NSError(domain: "", code: -1))
}
switch httpResponse.statusCode {
case 200, 201:
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
throw APIError.decodingFailed(error.localizedDescription)
}
case 401:
throw APIError.unauthorized
case 400..<500:
let msg = String(data: data, encoding: .utf8) ?? ""
throw APIError.serverError(msg)
default:
throw APIError.requestFailed(httpResponse.statusCode)
}
}
}
// MARK: - Helper for encoding arbitrary Encodable
struct AnyEncodable: Encodable {
let value: Encodable
init(_ value: Encodable) { self.value = value }
func encode(to encoder: Encoder) throws { try value.encode(to: encoder) }
}

View File

@ -0,0 +1,15 @@
//
// APIConfig.swift
//
import Foundation
enum APIConfig {
static let baseURL = "http://81.70.187.179:3001"
static let timeout: TimeInterval = 30
static func url(_ path: String) -> URL {
URL(string: "\(baseURL)\(path)")!
}
}

View File

@ -0,0 +1,25 @@
//
// APIError.swift
//
import Foundation
enum APIError: LocalizedError {
case invalidURL
case requestFailed(Int)
case decodingFailed(String)
case networkError(Error)
case unauthorized
case serverError(String)
var errorDescription: String? {
switch self {
case .invalidURL: return "无效的请求地址"
case .requestFailed(let code): return "请求失败 (\(code))"
case .decodingFailed(let msg): return "数据解析失败: \(msg)"
case .networkError(let e): return e.localizedDescription
case .unauthorized: return "未授权,请重新登录"
case .serverError(let msg): return msg
}
}
}

View File

@ -0,0 +1,172 @@
//
// APIService.swift - API
//
import Foundation
// MARK: - Waitlist
@MainActor
class WaitlistService {
static let shared = WaitlistService()
private let client = APIClient.shared
func join(email: String, nickname: String?, devices: [String]?,
interests: [String]?, painpoint: String?, willingBeta: Bool = true) async throws -> WaitlistResponse {
let body = WaitlistCreateRequest(email: email, nickname: nickname, devices: devices,
interests: interests, painpoint: painpoint, willingBeta: willingBeta)
return try await client.request("/waitlist", method: "POST", body: body)
}
func stats() async throws -> WaitlistStats {
return try await client.request("/waitlist/stats")
}
}
// MARK: - Auth
@MainActor
class AuthService {
static let shared = AuthService()
private let client = APIClient.shared
func appleLogin(identityToken: String, givenName: String?, familyName: String?) async throws -> AuthResponse {
let body = AppleAuthRequest(
identityToken: identityToken,
fullName: givenName != nil
? AppleAuthRequest.AppleFullName(givenName: givenName, familyName: familyName)
: nil
)
let resp: AuthResponse = try await client.request("/auth/apple", method: "POST", body: body)
if let token = resp.data?.accessToken {
await client.setToken(token)
}
return resp
}
func logout() async throws {
let _: GenericSuccessResponse = try await client.request("/auth/logout", method: "POST")
await client.setToken(nil)
}
}
// MARK: - User
@MainActor
class UserService {
static let shared = UserService()
private let client = APIClient.shared
func myProfile() async throws -> UserProfileResponse {
return try await client.request("/users/me")
}
func updateProfile(nickname: String?, preferences: UserPreferences?) async throws -> UserProfileResponse {
let body = UpdateUserRequest(nickname: nickname, preferences: preferences)
return try await client.request("/users/me", method: "PATCH", body: body)
}
}
// MARK: - Knowledge Base
@MainActor
class KnowledgeBaseService {
static let shared = KnowledgeBaseService()
private let client = APIClient.shared
func list() async throws -> KnowledgeBaseListResponse {
return try await client.request("/knowledge-bases")
}
func create(name: String, description: String?, icon: String?) async throws -> KnowledgeBaseListResponse {
let body = CreateKnowledgeBaseRequest(name: name, description: description, icon: icon)
return try await client.request("/knowledge-bases", method: "POST", body: body)
}
func detail(id: String) async throws -> KnowledgeBaseListResponse {
return try await client.request("/knowledge-bases/\(id)")
}
}
// MARK: - Knowledge Items
@MainActor
class KnowledgeItemService {
static let shared = KnowledgeItemService()
private let client = APIClient.shared
func list(baseId: String) async throws -> KnowledgeItemListResponse {
return try await client.request("/knowledge-items", queryItems: [URLQueryItem(name: "baseId", value: baseId)])
}
func detail(id: String) async throws -> KnowledgeItemListResponse {
return try await client.request("/knowledge-items/\(id)")
}
func create(baseId: String, title: String, content: String?, tags: [String]?) async throws -> KnowledgeItemListResponse {
let body = CreateKnowledgeItemRequest(title: title, content: content, baseId: baseId, tags: tags)
return try await client.request("/knowledge-items", method: "POST", body: body)
}
}
// MARK: - AI Analysis
@MainActor
class AIAnalysisService {
static let shared = AIAnalysisService()
private let client = APIClient.shared
func analyze(text: String, type: String = "weakness", context: AIAnalysisRequest.AIAnalysisContext? = nil) async throws -> AIAnalysisResponse {
let body = AIAnalysisRequest(text: text, type: type, context: context)
return try await client.request("/ai-analysis", method: "POST", body: body)
}
}
// MARK: - Activity & Stats
@MainActor
class ActivityService {
static let shared = ActivityService()
private let client = APIClient.shared
func summary() async throws -> ActivitySummaryResponse {
return try await client.request("/activity/summary")
}
}
// MARK: - Reviews
@MainActor
class ReviewService {
static let shared = ReviewService()
private let client = APIClient.shared
func due() async throws -> ReviewListResponse {
return try await client.request("/reviews/due")
}
}
// MARK: - Focus Items / Weak Points
@MainActor
class FocusItemService {
static let shared = FocusItemService()
private let client = APIClient.shared
func list() async throws -> FocusItemListResponse {
return try await client.request("/focus-items")
}
}
// MARK: - Feedback
@MainActor
class FeedbackService {
static let shared = FeedbackService()
private let client = APIClient.shared
func submit(type: String = "general", content: String, contact: String? = nil) async throws -> FeedbackResponse {
let body = FeedbackCreateRequest(type: type, content: content, contact: contact)
return try await client.request("/feedback", method: "POST", body: body)
}
}

View File

@ -1,11 +1,16 @@
// //
// AIHomeView.swift - Page 6: AI Home // AIHomeView.swift - Page 6: AI Home + API status indicator
// //
import SwiftUI 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 knowledgeCount = 0
@State private var navigateToChat = false
enum ServerStatus { case checking, online, offline }
var body: some View { var body: some View {
ZStack { ZStack {
@ -14,14 +19,31 @@ struct AIHomeView: View {
.frame(width:200,height:200).offset(x:80,y:-80).allowsHitTesting(false) .frame(width:200,height:200).offset(x:80,y:-80).allowsHitTesting(false)
VStack(spacing:0){ VStack(spacing:0){
// Header
HStack(alignment:.bottom){ HStack(alignment:.bottom){
VStack(alignment:.leading,spacing:2){ VStack(alignment:.leading,spacing:2){
Text("今天").font(.system(size:12,weight:.medium)).foregroundColor(Color.zxF04) Text("今天").font(.system(size:12,weight:.medium)).foregroundColor(Color.zxF04)
Text("AI 学习助手").font(.system(size:20,weight:.heavy)).foregroundColor(Color.zxF0).tracking(-0.4) Text("AI 学习助手").font(.system(size:20,weight:.heavy)).foregroundColor(Color.zxF0).tracking(-0.4)
} }
Spacer() Spacer()
ZXIconBtn(icon:"arrow.clockwise",size:36){}
HStack(spacing: 4) {
Circle()
.fill(serverStatus == .online ? Color.zxGreen
: serverStatus == .checking ? Color.zxYellow
: Color.zxRed)
.frame(width: 6, height: 6)
Text(serverStatus == .online ? "API \(knowledgeCount)"
: serverStatus == .checking ? "检测中…"
: "离线")
.font(.system(size: 10, weight: .medium))
.foregroundColor(serverStatus == .online ? Color.zxGreen
: serverStatus == .checking ? Color.zxYellow
: Color.zxF03)
}
.padding(.horizontal, 8).padding(.vertical, 4)
.background(Color.zxFill005).clipShape(Capsule())
ZXIconBtn(icon:"arrow.clockwise",size:36){ Task { await checkServer() } }
} }
.padding(.horizontal,20).padding(.top,ZXSpacing.statusBarH+16).padding(.bottom,12) .padding(.horizontal,20).padding(.top,ZXSpacing.statusBarH+16).padding(.bottom,12)
@ -38,6 +60,22 @@ struct AIHomeView: View {
inputBar inputBar
} }
NavigationLink(destination: AIChatPage(), isActive: $navigateToChat) { EmptyView() }
}
.task { await checkServer() }
}
private func checkServer() async {
serverStatus = .checking
do {
let resp: KnowledgeBaseListResponse = try await APIClient.shared.request("/knowledge-bases")
let count = resp.count
knowledgeCount = count
serverStatus = .online
} catch {
serverStatus = .offline
print("[API] 服务器检测失败: \(error.localizedDescription)")
} }
} }
@ -66,10 +104,18 @@ struct AIHomeView: View {
private var quickActions: some View { private var quickActions: some View {
HStack(spacing:12){ HStack(spacing:12){
ZXQuickAction(emoji:"🧠",label:"生成\n回忆测试") NavigationLink(destination: ActiveRecallView()) {
ZXQuickAction(emoji:"🔍",label:"分析\n薄弱点") ZXQuickAction(emoji:"🧠",label:"生成\n回忆测试")
ZXQuickAction(emoji:"🎤",label:"费曼\n解释练习") }.foregroundColor(.primary)
ZXQuickAction(emoji:"📅",label:"今日\n复习计划") NavigationLink(destination: WeakPointsPage()) {
ZXQuickAction(emoji:"🔍",label:"分析\n薄弱点")
}.foregroundColor(.primary)
NavigationLink(destination: AIChatPage()) {
ZXQuickAction(emoji:"🎤",label:"费曼\n解释练习")
}.foregroundColor(.primary)
NavigationLink(destination: ReviewCardView()) {
ZXQuickAction(emoji:"📅",label:"今日\n复习计划")
}.foregroundColor(.primary)
} }
} }
@ -80,11 +126,11 @@ struct AIHomeView: View {
Spacer();Text("全部").font(.system(size:12)).foregroundColor(Color.zxPurple) Spacer();Text("全部").font(.system(size:12)).foregroundColor(Color.zxPurple)
} }
ZXAIInteractionRow(tag:"费曼复习",bg:Color(hex:"#7C6EFA",opacity:0.15),fg:Color.zxPurple,emoji:"🎤", ZXAIInteractionRow(tag:"费曼复习",bg:Color(hex:"#7C6EFA",opacity:0.15),fg:Color.zxPurple,emoji:"🎤",
title:"解释量子纠缠的核心概念",time:"2小时前",score:82){} title:"解释量子纠缠的核心概念",time:"2小时前",score:82){ navigateToChat = true }
ZXAIInteractionRow(tag:"薄弱点",bg:Color(hex:"#F97316",opacity:0.15),fg:Color(hex:"#FBA574"),emoji:"⚠️", ZXAIInteractionRow(tag:"薄弱点",bg:Color(hex:"#F97316",opacity:0.15),fg:Color(hex:"#FBA574"),emoji:"⚠️",
title:"混淆了协方差和相关系数",time:"昨天",score:56){} title:"混淆了协方差和相关系数",time:"昨天",score:56){ navigateToChat = true }
ZXAIInteractionRow(tag:"回忆测试",bg:Color(hex:"#7C6EFA",opacity:0.15),fg:Color.zxAccent,emoji:"📝", ZXAIInteractionRow(tag:"回忆测试",bg:Color(hex:"#7C6EFA",opacity:0.15),fg:Color.zxAccent,emoji:"📝",
title:"机器学习中的偏差-方差权衡",time:"2天前",score:91){} title:"机器学习中的偏差-方差权衡",time:"2天前",score:91){ navigateToChat = true }
} }
} }
@ -92,13 +138,15 @@ struct AIHomeView: View {
VStack(alignment:.leading,spacing:10){ VStack(alignment:.leading,spacing:10){
Text("💡 你可以问 AI").font(.system(size:12,weight:.semibold)).foregroundColor(Color.zxF04) Text("💡 你可以问 AI").font(.system(size:12,weight:.semibold)).foregroundColor(Color.zxF04)
ForEach(["\"帮我测试机器学习这章的掌握情况\"","\"我最近的薄弱知识点有哪些?\"","\"生成一份本周的复习计划\""],id:\.self){s in ForEach(["\"帮我测试机器学习这章的掌握情况\"","\"我最近的薄弱知识点有哪些?\"","\"生成一份本周的复习计划\""],id:\.self){s in
HStack{ Button { text = s; navigateToChat = true } label: {
Text(s).font(.system(size:12)).foregroundColor(Color(hex:"#F0F0FF",opacity:0.55)).lineSpacing(4) HStack{
Spacer() Text(s).font(.system(size:12)).foregroundColor(Color(hex:"#F0F0FF",opacity:0.55)).lineSpacing(4)
Image(systemName:"arrow.up").font(.system(size:12)).foregroundColor(Color(hex:"#7C6EFA",opacity:0.5)) Spacer()
} Image(systemName:"arrow.up").font(.system(size:12)).foregroundColor(Color(hex:"#7C6EFA",opacity:0.5))
.padding(.horizontal,12).padding(.vertical,8) }
.background(Color(hex:"#7C6EFA",opacity:0.06)).clipShape(RoundedRectangle(cornerRadius:10)) .padding(.horizontal,12).padding(.vertical,8)
.background(Color(hex:"#7C6EFA",opacity:0.06)).clipShape(RoundedRectangle(cornerRadius:10))
}.foregroundColor(.primary)
} }
} }
.padding(14).padding(.horizontal,2) .padding(14).padding(.horizontal,2)
@ -113,7 +161,7 @@ struct AIHomeView: View {
TextField("问 AI 任何学习问题…",text:$text).font(.system(size:14)).tint(Color.zxPurple) TextField("问 AI 任何学习问题…",text:$text).font(.system(size:14)).tint(Color.zxPurple)
Spacer() Spacer()
Image(systemName:"mic.fill").font(.system(size:18)).foregroundColor(Color.zxF03) Image(systemName:"mic.fill").font(.system(size:18)).foregroundColor(Color.zxF03)
Button{}label:{ Button{ navigateToChat = true }label:{
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))
} }
@ -127,19 +175,48 @@ struct AIHomeView: View {
} }
} }
// Shared UI pieces struct ZXQuickAction: View {
let emoji: String
let label: String
struct ZXQuickAction: View {let emoji:String;let label:String var body: some View {
var body: some View {VStack(spacing:6){Text(emoji).font(.system(size:20)) VStack(spacing:6){
Text(label).font(.system(size:10,weight:.semibold)).foregroundColor(Color.zxF006).multilineTextAlignment(.center).lineSpacing(4)} Text(emoji).font(.system(size:22))
.frame(maxWidth:.infinity).frame(height:72).background(Color.zxFill003) Text(label).font(.system(size:10,weight:.medium)).foregroundColor(Color.zxF03)
.overlay(RoundedRectangle(cornerRadius:16).stroke(Color.zxBorder006,lineWidth:1)).clipShape(RoundedRectangle(cornerRadius:16))}} .multilineTextAlignment(.center).lineSpacing(2)
}
struct ZXAIInteractionRow: View {let tag:String;let bg:Color;let fg:Color;let emoji:String;let title:String;let time:String;let score:Int;let action:()->Void .frame(width:72,height:72)
var body: some View {Button(action:action){HStack(spacing:12){ .background(Color.zxFill005).clipShape(RoundedRectangle(cornerRadius:16))
Text(emoji).font(.system(size:18)).frame(width:40,height:40).background(bg).clipShape(RoundedRectangle(cornerRadius:12)) }
VStack(alignment:.leading,spacing:2){HStack(spacing:8){Text(tag).font(.system(size:10,weight:.bold)).foregroundColor(fg).tracking(0.3);Text(time).font(.system(size:10)).foregroundColor(Color.zxF03)};Text(title).font(.system(size:13,weight:.semibold)).foregroundColor(Color(hex:"#F0F0FF",opacity:0.8)).lineLimit(1)} }
Spacer()
ZXScoreBox(score:score,bg:score>=80 ? Color.zxGreenBG(0.15) : score>=60 ? Color.zxOrangeBG(0.15):Color.zxRedBG(0.15),fg:score>=80 ? Color.zxGreen : score>=60 ? Color.zxOrange:Color.zxRed)} struct ZXAIInteractionRow: View {
.padding(.horizontal,14).padding(.vertical,12).background(Color.zxFill003).overlay(RoundedRectangle(cornerRadius:16).stroke(Color.zxBorder006,lineWidth:1)).clipShape(RoundedRectangle(cornerRadius:16))}} let tag: String
let bg: Color
let fg: Color
let emoji: String
let title: String
let time: String
let score: Int
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing:12){
Text(emoji).font(.system(size:16))
.frame(width:36,height:36).background(bg).clipShape(RoundedRectangle(cornerRadius:10))
VStack(alignment:.leading,spacing:4){
HStack{
Text(tag).font(.system(size:10,weight:.bold)).foregroundColor(fg)
Text(time).font(.system(size:10)).foregroundColor(Color.zxF04)
}
Text(title).font(.system(size:13,weight:.medium)).foregroundColor(Color.zxF0)
}.frame(maxWidth:.infinity,alignment:.leading)
Text("\(score)").font(.system(size:12,weight:.heavy)).foregroundColor(Color.zxYellow)
.frame(width:28,height:28).background(Color.zxYellowBG(0.1)).clipShape(RoundedRectangle(cornerRadius:8))
}
.padding(.horizontal,14).padding(.vertical,12)
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius:14))
}
}
} }

View File

@ -0,0 +1,188 @@
import SwiftUI
struct ActiveRecallView: View {
let questions: [RecallQuestion] = [
.init(id: "1", question: "请解释贝叶斯定理的核心思想,并写出公式", source: "机器学习 · 概率论", isVoice: false),
.init(id: "2", question: "请用自己的话解释梯度下降算法的工作原理", source: "机器学习 · 优化算法", isVoice: false),
.init(id: "3", question: "用费曼学习法解释「过拟合与欠拟合」的区别", source: "机器学习 · 模型选择", isVoice: true),
]
@State private var idx = 0
@State private var answers: [String: String] = [:]
@State private var currentAnswer = ""
@State private var submitted: Set<String> = []
@State private var showFinish = false
var current: RecallQuestion { questions[idx] }
var body: some View {
ZStack {
Color.zxBg0.ignoresSafeArea()
VStack(spacing: 0) {
progressHeader
ScrollView {
VStack(spacing: 16) {
questionCard
if !isSubmitted {
answerInput
} else {
submittedView
}
}
.padding(.horizontal, 20)
.padding(.top, 12)
.padding(.bottom, 120)
}
.scrollIndicators(.hidden)
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
}
private var isSubmitted: Bool { submitted.contains(current.id) }
private var progressHeader: some View {
VStack(spacing: 8) {
HStack {
Text("主动回忆 \(idx + 1)/\(questions.count)")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.zxF04)
Spacer()
Text("已答 \(submitted.count)")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.zxPurple)
}
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2).fill(Color.zxFill008).frame(height: 3)
RoundedRectangle(cornerRadius: 2)
.fill(ZXGradient.progressBar)
.frame(width: max(3, CGFloat(idx + 1) / CGFloat(questions.count) * (UIScreen.main.bounds.width - 40)), height: 3)
}
}
.padding(.horizontal, 20)
.padding(.top, 8)
.padding(.bottom, 4)
}
private var questionCard: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
if current.isVoice {
Image(systemName: "mic.fill").font(.system(size: 12)).foregroundColor(Color.zxOrange)
Text("语音题").font(.system(size: 10, weight: .bold)).foregroundColor(Color.zxOrange)
.padding(.horizontal, 6).padding(.vertical, 2).background(Color.zxOrangeBG(0.1)).clipShape(Capsule())
} else {
Image(systemName: "pencil.line").font(.system(size: 12)).foregroundColor(Color.zxPurple)
Text("文字题").font(.system(size: 10, weight: .bold)).foregroundColor(Color.zxPurple)
.padding(.horizontal, 6).padding(.vertical, 2).background(Color.zxPurpleBG(0.1)).clipShape(Capsule())
}
Spacer()
Text(current.source).font(.system(size: 10)).foregroundColor(Color.zxF03)
}
Text(current.question)
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Color.zxF0)
.lineSpacing(5)
}
.padding(16)
.background(ZXGradient.thinkingCard)
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 16))
}
private var answerInput: some View {
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("你的回答").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035)
if current.isVoice {
voiceInputArea
} else {
TextEditor(text: $currentAnswer)
.font(.system(size: 13))
.foregroundColor(Color.zxF0)
.tint(Color.zxPurple)
.frame(minHeight: 150)
.scrollContentBackground(.hidden)
.padding(12)
.background(Color.zxFill004)
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
}
}
Button {
answers[current.id] = current.isVoice ? "语音答案已录制" : currentAnswer
submitted.insert(current.id)
currentAnswer = ""
} label: {
Text("提交回答")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.white)
.frame(maxWidth: .infinity).frame(height: 52)
.background(ZXGradient.ctaPurple)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
.disabled(currentAnswer.isEmpty && !current.isVoice)
.opacity(currentAnswer.isEmpty && !current.isVoice ? 0.5 : 1)
}
}
private var voiceInputArea: some View {
VStack(spacing: 12) {
ZStack {
Circle().fill(Color.zxOrangeBG(0.1)).frame(width: 80, height: 80)
Image(systemName: "mic.fill").font(.system(size: 32)).foregroundColor(Color.zxOrange)
}
Text("点击按钮开始录音,用费曼方法口头解释").font(.system(size: 12)).foregroundColor(Color.zxF04)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
.background(Color.zxFill004)
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
}
private var submittedView: some View {
VStack(spacing: 16) {
HStack(spacing: 10) {
Image(systemName: "checkmark.circle.fill").font(.system(size: 22)).foregroundColor(Color.zxGreen)
VStack(alignment: .leading, spacing: 3) {
Text("回答已提交").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxGreen)
Text("AI 分析中,稍后可查看反馈").font(.system(size: 12)).foregroundColor(Color.zxF04)
}
Spacer()
}
.padding(16)
.background(Color.zxGreenBG(0.06))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color(hex: "#34D399", opacity: 0.15), lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 14))
if idx < questions.count - 1 {
Button { idx += 1 } label: {
Label("下一题", systemImage: "arrow.right")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.white)
.frame(maxWidth: .infinity).frame(height: 52)
.background(ZXGradient.brand)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
} else {
NavigationLink(destination: AIFeedbackPageView()) {
Label("查看 AI 分析结果", systemImage: "sparkles")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.white)
.frame(maxWidth: .infinity).frame(height: 52)
.background(ZXGradient.ctaPurple)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
}
}
}
}
struct RecallQuestion: Identifiable {
let id: String
let question: String
let source: String
let isVoice: Bool
}

View File

@ -1,196 +1,46 @@
//
// DailyThinkingPage.swift - Page 14: Daily Thinking
//
import SwiftUI import SwiftUI
struct DailyThinkingPage: View { struct DailyThinkingPage: View {
@State private var answer = "" @State private var answer = ""; @State private var submitted = false
@State private var submitted = false
var body: some View { var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea() ZStack { Color.zxBg0.ignoresSafeArea()
VStack(spacing: 0) { ScrollView { VStack(spacing: 16) {
ZXBackHeader(title: "今日思考", subtitle: "过拟合", onBack: nil, trailing: { VStack(alignment: .leading, spacing: 12) {
ZXIconBtn(icon: "bookmark", size: 36) {} HStack { Image(systemName:"sparkles").foregroundColor(Color.zxAccent); Text("解释\"注意力机制\"在 Transformer 中的作用").font(.system(size:15,weight:.bold)).foregroundColor(Color.zxF0) }
}) Text("AI会从三个方面评估你的回答核心概念理解 · 理论深度 · 实际应用能力").font(.system(size:12)).foregroundColor(Color.zxF04)
ScrollView { VStack(spacing: 16) { }.padding(16).background(ZXGradient.thinkingCard).clipShape(RoundedRectangle(cornerRadius:16))
VStack(alignment: .leading, spacing: 12) { 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))}
HStack { 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) } }
Image(systemName: "sparkles").foregroundColor(Color.zxAccent) }.padding(.horizontal,20).padding(.top, 8).padding(.bottom,120) }.scrollIndicators(.hidden)
Text("解释\"注意力机制\"在 Transformer 中的作用").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0) }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden,for:.navigationBar)
}
Text("AI会从三个方面评估你的回答核心概念理解 · 理论深度 · 实际应用能力").font(.system(size: 12)).foregroundColor(Color.zxF04)
}
.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))
}
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)
}
}
}.padding(.horizontal, 20).padding(.bottom, 120) }
.scrollIndicators(.hidden)
}
}
.navigationBarHidden(true)
} }
} }
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)} }
// Back Header struct WeakPointsPage: View { var body: some View { ZStack{Color.zxBg0.ignoresSafeArea();ScrollView{VStack(spacing:12){
struct ZXBackHeader<T: View>: View { NavigationLink(destination: KnowledgeDetailPage()) { ZXWeakRow(score:32,topic:"贝叶斯定理应用",lib:"机器学习",priority:"") }.foregroundColor(.primary)
let title: String; let subtitle: String?; var onBack: (() -> Void)? NavigationLink(destination: KnowledgeDetailPage()) { ZXWeakRow(score:41,topic:"正态分布性质",lib:"高等数学",priority:"") }.foregroundColor(.primary)
@ViewBuilder var trailing: () -> T ZXWeakRow(score:55,topic:"词根 spect- 相关词汇",lib:"英语词汇",priority:"")
@Environment(\.dismiss) private var dismiss ZXWeakRow(score:48,topic:"协方差与相关系数",lib:"机器学习",priority:"")
var body: some View { ZXWeakRow(score:36,topic:"梯度下降优化",lib:"机器学习",priority:"")
HStack { }.padding(.horizontal,20).padding(.top, 8).padding(.bottom,80)}.scrollIndicators(.hidden)}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden,for:.navigationBar)} }
Button { (onBack ?? { dismiss() })() } label: {
Image(systemName: "chevron.left").font(.system(size: 18)).foregroundColor(Color.zxF007)
.frame(width: 36, height: 36).background(Color.zxFill005).clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1))
}
VStack(spacing: 1) {
Text(title).font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0)
if let s = subtitle { Text(s).font(.system(size: 11)).foregroundColor(Color.zxF03) }
}.frame(maxWidth: .infinity)
trailing()
}
.padding(.horizontal, 16).padding(.top, ZXSpacing.statusBarH + 8).padding(.bottom, 12)
.background(Color.zxBg0)
}
}
// RecallTest
struct RecallTestPage: View {
@State private var input = ""
var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea()
VStack(spacing: 0) {
ZXBackHeader(title: "回忆测试", subtitle: "机器学习 · 偏差-方差权衡") {}
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))
Button {} 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(.bottom, 80) }.scrollIndicators(.hidden)
}
}.navigationBarHidden(true)
}
}
// WeakPoints
struct WeakPointsPage: View {
var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea()
VStack(spacing: 0) {
ZXBackHeader(title: "薄弱知识点", subtitle: "共 23 个待巩固") {}
ScrollView { VStack(spacing: 12) {
ZXWeakRow(score: 32, topic: "贝叶斯定理应用", lib: "机器学习", priority: "")
ZXWeakRow(score: 41, topic: "正态分布性质", lib: "高等数学", priority: "")
ZXWeakRow(score: 55, topic: "词根 spect- 相关词汇", lib: "英语词汇", priority: "")
ZXWeakRow(score: 48, topic: "协方差与相关系数", lib: "机器学习", priority: "")
ZXWeakRow(score: 36, topic: "梯度下降优化", lib: "机器学习", priority: "")
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden)
}
}.navigationBarHidden(true)
}
}
// AIFeedback - Page 15
struct AIFeedbackPageView: View { struct AIFeedbackPageView: View {
@State private var navigateToChat = false
var body: some View { var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea() 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))
VStack(spacing: 0) { 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))}
ZXBackHeader(title: "AI 反馈", subtitle: "今日思考 · 过拟合", trailing: { 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))}}
ZXIconBtn(icon: "bookmark", size: 36) {} 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)
ScrollView { VStack(spacing: 16) { }
// Score HStack(spacing:12){
HStack(spacing: 20) { NavigationLink(destination: AIChatPage()) {
ZStack { 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))
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))
}
}
//
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill").foregroundColor(Color.zxYellow); Text("需要完善").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0) }
ForEach([("缺少对「方差」和「偏差」权衡的说明", "过拟合本质是高方差问题,可以提到偏差-方差权衡"),("未提及正则化、Dropout 等解决方案", "完整答案通常要说明\"如何解决\"")], id: \.0) { p, d in
VStack(alignment: .leading, spacing: 4) { Text(p).font(.system(size: 13, weight: .bold)).foregroundColor(Color.zxF0); Text(d).font(.system(size: 12)).foregroundColor(Color.zxF05).lineSpacing(4) }
.padding(14).frame(maxWidth: .infinity, alignment: .leading).background(Color(hex: "#F59E0B", opacity: 0.07)).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color(hex: "#F59E0B", opacity: 0.18), lineWidth: 1))
}
}
//
VStack(alignment: .leading, spacing: 8) {
Text("✨ 参考答案要点").font(.system(size: 13, weight: .bold)).foregroundColor(Color.zxAccent)
Text("过拟合是模型复杂度过高导致的高方差问题。偏差-方差权衡是核心概念。解决方法包括正则化、Dropout、数据增强、早停等。").font(.system(size: 13)).foregroundColor(Color.zxF007).lineSpacing(6)
}.padding(16).background(Color(hex: "#7C6EFA", opacity: 0.07)).clipShape(RoundedRectangle(cornerRadius: 16)).overlay(RoundedRectangle(cornerRadius: 16).stroke(Color(hex: "#7C6EFA", opacity: 0.2), lineWidth: 1))
//
Button {} label: { 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) { ZXOutlineBtn(text: "深入提问"); ZXOutlineBtn(text: "再来一题") }
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden)
} }
}.navigationBarHidden(true) 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))
} }
struct ZXOutlineBtn: View { let text: String }
var body: some View { Button {} label: { HStack(spacing: 4) { Text(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)}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden,for:.navigationBar)
}
// AIChat - Page 11
struct AIChatPage: View {
@State private var msg = ""
@State private var msgs: [(role: String, content: String)] = [("ai", "你好!我是你的 AI 学习助手。我可以帮你解答学习问题、分析薄弱点、制定复习计划。请告诉我你想学习什么?")]
var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea()
VStack(spacing: 0) {
ZXBackHeader(title: "AI 对话", subtitle: "学习助手") {}
ScrollViewReader { proxy in ScrollView { VStack(spacing: 16) {
ForEach(Array(msgs.enumerated()), id: \.offset) { i, m in
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)
}
}.padding(.horizontal, 20).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", "好的,我理解你的问题。建议你从基础概念开始,逐步深入理解。需要我帮你制定具体的学习计划吗?")) } })
}
}.navigationBarHidden(true)
} }
} }
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

@ -1,7 +1,3 @@
//
// AnalysisHomeView.swift - Page 9: Analysis Home
//
import SwiftUI import SwiftUI
struct AnalysisHomeView: View { struct AnalysisHomeView: View {
@ -10,20 +6,12 @@ struct AnalysisHomeView: View {
Color.zxBg0.ignoresSafeArea() Color.zxBg0.ignoresSafeArea()
VStack(spacing: 0) { VStack(spacing: 0) {
HStack { HStack {
Text("学习分析") Text("学习分析").font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxF0).tracking(-0.5)
.font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxF0).tracking(-0.5)
Spacer() Spacer()
HStack(spacing: 4) { HStack(spacing: 4) { Text("近 7 天").font(.system(size: 12)).foregroundColor(Color.zxF05); Image(systemName: "chevron.down").font(.system(size: 10)).foregroundColor(Color.zxF04) }
Text("近 7 天").font(.system(size: 12)).foregroundColor(Color.zxF05) .padding(.horizontal, 12).padding(.vertical, 6).background(Color.zxFill005).clipShape(Capsule()).overlay(Capsule().stroke(Color.zxBorder008, lineWidth: 1))
Image(systemName: "chevron.down").font(.system(size: 10)).foregroundColor(Color.zxF04)
}
.padding(.horizontal, 12).padding(.vertical, 6)
.background(Color.zxFill005)
.clipShape(Capsule())
.overlay(Capsule().stroke(Color.zxBorder008, lineWidth: 1))
} }
.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) {
HStack(spacing: 12) { HStack(spacing: 12) {
@ -32,117 +20,46 @@ struct AnalysisHomeView: View {
ZXStatBadge(icon: "exclamationmark.triangle.fill", label: "待巩固", value: "23", trend: "-5", color: Color.zxYellow) ZXStatBadge(icon: "exclamationmark.triangle.fill", label: "待巩固", value: "23", trend: "-5", color: Color.zxYellow)
ZXStatBadge(icon: "chart.line.uptrend.xyaxis", label: "连续天", value: "14", trend: "🔥", color: Color.zxGreen) ZXStatBadge(icon: "chart.line.uptrend.xyaxis", label: "连续天", value: "14", trend: "🔥", color: Color.zxGreen)
} }
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
HStack { HStack { Text("掌握度趋势").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0); Spacer(); Text("↑ +8% 本周").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxGreen) }
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 { 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(spacing: 8) { NavigationLink(destination: KnowledgeDetailPage()) { ZXWeakRow(score: 32, topic: "贝叶斯定理应用", lib: "机器学习", priority: "") }.foregroundColor(.primary)
Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 14)).foregroundColor(Color.zxYellow) NavigationLink(destination: KnowledgeDetailPage()) { ZXWeakRow(score: 41, topic: "正态分布性质", lib: "高等数学", priority: "") }.foregroundColor(.primary)
Text("薄弱知识点").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0)
}
Spacer()
Text("全部 23 个").font(.system(size: 12)).foregroundColor(Color.zxPurple)
}
ZXWeakRow(score: 32, topic: "贝叶斯定理应用", lib: "机器学习", priority: "")
ZXWeakRow(score: 41, topic: "正态分布性质", lib: "高等数学", priority: "")
ZXWeakRow(score: 55, topic: "词根 spect- 相关词汇", lib: "英语词汇", priority: "") ZXWeakRow(score: 55, topic: "词根 spect- 相关词汇", lib: "英语词汇", priority: "")
} }
} }.padding(.horizontal, 20).padding(.bottom, 120)
.padding(.horizontal, 20) }.scrollIndicators(.hidden)
.padding(.bottom, 120)
}
.scrollIndicators(.hidden)
} }
} }
} }
} }
// MARK: - Stat Badge struct ZXStatBadge: View { let icon: String; let label: String; let value: String; let trend: String; let color: Color
struct ZXStatBadge: View {
let icon: String; let label: String; let value: String; let trend: String; let color: Color
var body: some View { var body: some View {
VStack(spacing: 3) { VStack(spacing: 3) {
Image(systemName: icon).font(.system(size: 14)).foregroundColor(color) Image(systemName: icon).font(.system(size: 14)).foregroundColor(color)
Text(value).font(.system(size: 16, weight: .heavy)).foregroundColor(Color.zxF0) Text(value).font(.system(size: 16, weight: .heavy)).foregroundColor(Color.zxF0)
Text(label).font(.system(size: 9)).foregroundColor(Color.zxF04).multilineTextAlignment(.center) Text(label).font(.system(size: 9)).foregroundColor(Color.zxF04).multilineTextAlignment(.center)
} }.frame(maxWidth: .infinity).frame(height: 72).padding(.vertical, 4).background(color.opacity(0.06)).overlay(RoundedRectangle(cornerRadius: 14).stroke(color.opacity(0.15), lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 14))
.frame(maxWidth: .infinity).frame(height: 72).padding(.vertical, 4)
.background(color.opacity(0.06))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(color.opacity(0.15), lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 14))
} }
} }
// MARK: - Weak Point Row
struct ZXWeakRow: View {
let score: Int; let topic: String; let lib: String; let priority: String
var body: some View {
HStack(spacing: 12) {
Text("\(score)").font(.system(size: 13, weight: .heavy)).foregroundColor(Color.zxYellow)
.frame(width: 40, height: 40)
.background(Color.zxYellowBG(0.15)).clipShape(RoundedRectangle(cornerRadius: 12))
VStack(alignment: .leading, spacing: 2) {
Text(topic).font(.system(size: 13, weight: .semibold)).foregroundColor(Color.zxF0)
Text(lib).font(.system(size: 11)).foregroundColor(Color.zxF04)
}.frame(maxWidth: .infinity, alignment: .leading)
Text("\(priority)优先").font(.system(size: 11, weight: .bold))
.foregroundColor(priority == "" ? Color.zxRed : Color.zxYellow)
.padding(.horizontal, 8).padding(.vertical, 3)
.background((priority == "" ? Color.zxRedBG(0.15) : Color.zxYellowBG(0.15)))
.clipShape(Capsule())
}
.padding(.horizontal, 16).padding(.vertical, 12)
.background(Color.zxYellowBG(0.06))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color(hex: "#F59E0B", opacity: 0.15), lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 14))
}
}
// MARK: - Chart View
struct ZXChartView: View { struct ZXChartView: View {
let data: [(String, CGFloat)] = [ let data: [(String, CGFloat)] = [("", 0.62), ("", 0.65), ("", 0.71), ("", 0.68), ("", 0.75), ("", 0.79), ("", 0.78)]
("", 0.62), ("", 0.65), ("", 0.71), ("", 0.68),
("", 0.75), ("", 0.79), ("", 0.78)
]
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
GeometryReader { g in GeometryReader { g in
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
Path { path in Path { path in let w = g.size.width / 7
let w = g.size.width / 7 for (i, d) in data.enumerated() { let x = w * CGFloat(i) + w / 2; let y = (1 - d.1) * g.size.height
for (i, d) in data.enumerated() { if i == 0 { path.move(to: CGPoint(x: x, y: y)) } else { path.addLine(to: CGPoint(x: x, y: y)) } }
let x = w * CGFloat(i) + w / 2 }.stroke(Color.zxPurple, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
let y = (1 - d.1) * g.size.height
if i == 0 { path.move(to: CGPoint(x: x, y: y)) }
else { path.addLine(to: CGPoint(x: x, y: y)) }
}
}
.stroke(Color.zxPurple, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
} }
} }.frame(height: 100)
.frame(height: 100) HStack(spacing: 0) { ForEach(data, id: \.0) { d in Text(d.0).font(.system(size: 9)).foregroundColor(Color(hex: "#F0F0FF", opacity: 0.35)).frame(maxWidth: .infinity) } }
HStack(spacing: 0) {
ForEach(data, id: \.0) { d in
Text(d.0).font(.system(size: 9))
.foregroundColor(Color(hex: "#F0F0FF", opacity: 0.35))
.frame(maxWidth: .infinity)
}
}
} }
} }
} }

View File

@ -9,7 +9,19 @@ struct LibraryHomeView: View {
var body: some View { var body: some View {
ZStack { ZXGradient.page.ignoresSafeArea() ZStack { ZXGradient.page.ignoresSafeArea()
VStack(spacing: 0) { VStack(spacing: 0) {
HStack { Text("知识库").font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxF0).tracking(-0.5); Spacer(); ZXIconBtn(icon: "magnifyingglass", size: 36) {}; ZXIconBtn(icon: "plus", size: 36, branded: true) {} } HStack { Text("知识库").font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxF0).tracking(-0.5); Spacer()
NavigationLink(destination: LibrarySearchView()) {
Image(systemName: "magnifyingglass").font(.system(size: 18)).foregroundColor(Color.zxF05)
.frame(width: 36, height: 36).background(Color(hex:"#FFFFFF",opacity:0.05))
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1))
}
NavigationLink(destination: ImportPage()) {
Image(systemName: "plus").font(.system(size: 18)).foregroundColor(.white)
.frame(width: 36, height: 36).background(ZXGradient.brand)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
.padding(.horizontal, 20).padding(.top, 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) }
.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)
@ -33,3 +45,24 @@ struct ZLibraryCard: View { let emoji: String; let name: String; let desc: Strin
var body: some View { VStack(spacing: 0) { Rectangle().fill(ZXGradient.progressBar).frame(height: 3); HStack(spacing: 12) { Text(emoji).font(.system(size: 22)).frame(width: 44, height: 44).background(color.opacity(0.12)).clipShape(RoundedRectangle(cornerRadius: 13)).overlay(RoundedRectangle(cornerRadius: 13).stroke(color.opacity(0.3), lineWidth: 1)); VStack(alignment: .leading, spacing: 2) { Text(name).font(.system(size: 16, weight: .bold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF04); Text("掌握 \(mastery)%").font(.system(size: 11)).foregroundColor(Color.zxF04) }; Spacer() }.padding(16); HStack { HStack(spacing: 4) { Image(systemName: "clock").font(.system(size: 10)); Text("\(items) 项 · \(last)").font(.system(size: 11)) }.foregroundColor(Color.zxF03); Spacer(); ForEach(tags.prefix(2), id: \.self) { t in Text(t).font(.system(size: 10, weight: .medium)).foregroundColor(Color.zxPurple).padding(.horizontal, 7).padding(.vertical, 2).background(Color(hex: "#7C6EFA", opacity: 0.08)).clipShape(Capsule()) } }.padding(.horizontal, 16).padding(.bottom, 12) } var body: some View { VStack(spacing: 0) { Rectangle().fill(ZXGradient.progressBar).frame(height: 3); HStack(spacing: 12) { Text(emoji).font(.system(size: 22)).frame(width: 44, height: 44).background(color.opacity(0.12)).clipShape(RoundedRectangle(cornerRadius: 13)).overlay(RoundedRectangle(cornerRadius: 13).stroke(color.opacity(0.3), lineWidth: 1)); VStack(alignment: .leading, spacing: 2) { Text(name).font(.system(size: 16, weight: .bold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF04); Text("掌握 \(mastery)%").font(.system(size: 11)).foregroundColor(Color.zxF04) }; Spacer() }.padding(16); HStack { HStack(spacing: 4) { Image(systemName: "clock").font(.system(size: 10)); Text("\(items) 项 · \(last)").font(.system(size: 11)) }.foregroundColor(Color.zxF03); Spacer(); ForEach(tags.prefix(2), id: \.self) { t in Text(t).font(.system(size: 10, weight: .medium)).foregroundColor(Color.zxPurple).padding(.horizontal, 7).padding(.vertical, 2).background(Color(hex: "#7C6EFA", opacity: 0.08)).clipShape(Capsule()) } }.padding(.horizontal, 16).padding(.bottom, 12) }
.background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 20)).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)) } .background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 20)).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)) }
} }
struct LibrarySearchView: View {
@State private var query = ""
var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea()
VStack(spacing: 0) {
HStack(spacing: 8) { Image(systemName: "magnifyingglass").font(.system(size: 16)).foregroundColor(Color.zxF03); TextField("搜索知识库或知识点…", text: $query).font(.system(size: 14)).tint(Color.zxPurple) }
.padding(.horizontal, 14).frame(height: 44).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 14))
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 16)
ScrollView { VStack(spacing: 12) {
if query.isEmpty {
VStack(spacing: 12) {
Image(systemName: "magnifyingglass").font(.system(size: 36)).foregroundColor(Color.zxF03)
Text("搜索知识点、知识库或标签").font(.system(size: 13)).foregroundColor(Color.zxF03)
}.padding(.top, 80)
}
}.padding(.horizontal, 20) }.scrollIndicators(.hidden)
}
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)
}
}

View File

@ -1,41 +1,33 @@
//
// LibrarySubpages.swift
//
import SwiftUI import SwiftUI
struct CreateLibraryPage: View { struct CreateLibraryPage: View {
@State private var name = ""; @State private var desc = "" @State private var name = ""; @State private var desc = ""
var body: some View { var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea() ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
VStack(spacing: 0) { ScrollView { VStack(spacing: 20) {
ZXBackHeader(title: "创建知识库", subtitle: nil) {} 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)) }
ScrollView { VStack(spacing: 20) { 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: $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)) } Button { } label: { Text("创建").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) }
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)) } }.padding(.horizontal, 20).padding(.top, 20) }.scrollIndicators(.hidden) }
Button {} label: { Text("创建").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) } }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)}
}.padding(.horizontal, 20).padding(.top, 20) }.scrollIndicators(.hidden)
}
}.navigationBarHidden(true)
}
} }
struct LibraryDetailPage: View { struct LibraryDetailPage: View {
var body: some View { var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea() ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
VStack(spacing: 0) { HStack { Spacer()
ZXBackHeader(title: "机器学习", subtitle: "47 个知识点 · 掌握 72%") { NavigationLink(destination: AddKnowledgePage()) {
HStack(spacing: 8) { ZXIconBtn(icon: "magnifyingglass", size: 36) {}; ZXIconBtn(icon: "plus", size: 36, branded: true) {} } Image(systemName: "plus").font(.system(size: 18)).foregroundColor(.white)
.frame(width: 36, height: 36).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: 10))
} }
ScrollView { VStack(spacing: 12) { }.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 8)
NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "偏差-方差权衡", desc: "模型复杂度 · 泛化误差", status: "已掌握", c: Color.zxGreen) } ScrollView { VStack(spacing: 12) {
NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "梯度下降优化", desc: "SGD · Adam · 学习率", status: "学习中", c: Color.zxOrange) } NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "偏差-方差权衡", desc: "模型复杂度 · 泛化误差", status: "已掌握", c: Color.zxGreen) }
NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "正则化方法", desc: "L1 · L2 · Dropout", status: "待复习", c: Color.zxYellow) } NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "梯度下降优化", desc: "SGD · Adam · 学习率", status: "学习中", c: Color.zxOrange) }
NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "过拟合与欠拟合", desc: "偏差方差 · 模型选择", status: "已掌握", c: Color.zxGreen) } NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "正则化方法", desc: "L1 · L2 · Dropout", status: "待复习", c: Color.zxYellow) }
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden) NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "过拟合与欠拟合", desc: "偏差方差 · 模型选择", status: "已掌握", c: Color.zxGreen) }
} }.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden) }
}.navigationBarHidden(true) }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)}
}
} }
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()) }
@ -45,31 +37,38 @@ struct ZXCardRow: View { let emoji: String; let title: String; let desc: String;
struct AddKnowledgePage: View { struct AddKnowledgePage: View {
@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() ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
VStack(spacing: 0) { ScrollView { VStack(spacing: 16) {
ZXBackHeader(title: "添加知识点", subtitle: "机器学习") {} 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)) }
ScrollView { VStack(spacing: 16) { 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); 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)) } Button { } label: { Text("保存").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) }
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)) } }.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80) }.scrollIndicators(.hidden) }
Button {} label: { Text("保存").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) } }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)}
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden)
}
}.navigationBarHidden(true)
}
} }
struct KnowledgeDetailPage: View { struct KnowledgeDetailPage: View {
var body: some View { var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea() ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
VStack(spacing: 0) { HStack { Spacer()
ZXBackHeader(title: "知识点详情", subtitle: "机器学习") { ZXIconBtn(icon: "pencil", size: 36) {} } NavigationLink(destination: EditKnowledgePage()) {
ScrollView { VStack(spacing: 16) { Image(systemName: "pencil").font(.system(size: 16)).foregroundColor(Color.zxF05)
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)) .frame(width: 36, height: 36).background(Color(hex:"#FFFFFF",opacity:0.05))
HStack(spacing: 12) { Button {} label: { 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)) }; Button {} label: { Label("费曼解释", systemImage: "mic.fill").font(.system(size: 14, weight: .semibold)).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)) } } .clipShape(RoundedRectangle(cornerRadius: 10))
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1))
} }
}.navigationBarHidden(true) }.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 8)
} ScrollView { VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 8) { HStack { ZXChip(text: "算法", color: Color.zxPurple); ZXChip(text: "机器学习", color: Color.zxAccent); ZXChip(text: "需要复习", color: Color.zxYellow) }; Text("偏差-方差权衡").font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxF0); Text("偏差-方差权衡是机器学习模型选择的核心理念。").font(.system(size: 14)).foregroundColor(Color.zxF007).lineSpacing(6) }.padding(20).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20)).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
HStack(spacing: 12) {
NavigationLink(destination: StudyHomeView()) {
Label("开始复习", systemImage: "arrow.triangle.2.circlepath").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 44).background(ZXGradient.brandPurple).clipShape(RoundedRectangle(cornerRadius: 14))
}
NavigationLink(destination: AIChatPage()) {
Label("费曼解释", systemImage: "mic.fill").font(.system(size: 14, weight: .semibold)).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(.bottom, 80) }.scrollIndicators(.hidden) }
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)}
} }
struct ZXChip: View { let text: String; let color: Color struct ZXChip: View { let text: String; let color: Color
var body: some View { Text(text).font(.system(size: 10, weight: .semibold)).foregroundColor(color).padding(.horizontal, 8).padding(.vertical, 2).background(color.opacity(0.12)).clipShape(Capsule()) } var body: some View { Text(text).font(.system(size: 10, weight: .semibold)).foregroundColor(color).padding(.horizontal, 8).padding(.vertical, 2).background(color.opacity(0.12)).clipShape(Capsule()) }
@ -77,35 +76,27 @@ struct ZXChip: View { let text: String; let color: Color
struct ImportPage: View { struct ImportPage: View {
var body: some View { var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea() ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
VStack(spacing: 0) { ScrollView { VStack(spacing: 12) {
ZXBackHeader(title: "导入资料", subtitle: nil) {} ZXImportOption(icon: "camera.fill", title: "拍照导入", desc: "拍下书本或笔记AI 自动识别")
ScrollView { VStack(spacing: 12) { ZXImportOption(icon: "doc.text.fill", title: "文件导入", desc: "支持 PDF、Word、Markdown")
ZXImportOption(icon: "camera.fill", title: "拍照导入", desc: "拍下书本或笔记AI 自动识别") ZXImportOption(icon: "link", title: "链接导入", desc: "粘贴网页链接,自动提取内容")
ZXImportOption(icon: "doc.text.fill", title: "文件导入", desc: "支持 PDF、Word、Markdown") ZXImportOption(icon: "photo.on.rectangle", title: "相册导入", desc: "从相册选择截图或图片")
ZXImportOption(icon: "link", title: "链接导入", desc: "粘贴网页链接,自动提取内容") }.padding(.horizontal, 20).padding(.top, 8) }.scrollIndicators(.hidden) }
ZXImportOption(icon: "photo.on.rectangle", title: "相册导入", desc: "从相册选择截图或图片") }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)}
}.padding(.horizontal, 20).padding(.top, 16) }.scrollIndicators(.hidden)
}
}.navigationBarHidden(true)
}
} }
struct ZXImportOption: View { let icon: String; let title: String; let desc: String struct ZXImportOption: View { let icon: String; let title: String; let desc: String
var body: some View { Button {} label: { HStack(spacing: 14) { Image(systemName: icon).font(.system(size: 22)).foregroundColor(Color.zxPurple).frame(width: 48, height: 48).background(Color.zxPurpleBG(0.1)).clipShape(RoundedRectangle(cornerRadius: 14)); VStack(alignment: .leading, spacing: 2) { Text(title).font(.system(size: 15, weight: .semibold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF04) }; Spacer(); Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(Color.zxF03) }.padding(16).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 16)).overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1)) }.foregroundColor(.primary) } var body: some View { Button { } label: { HStack(spacing: 14) { Image(systemName: icon).font(.system(size: 22)).foregroundColor(Color.zxPurple).frame(width: 48, height: 48).background(Color.zxPurpleBG(0.1)).clipShape(RoundedRectangle(cornerRadius: 14)); VStack(alignment: .leading, spacing: 2) { Text(title).font(.system(size: 15, weight: .semibold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF04) }; Spacer(); Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(Color.zxF03) }.padding(16).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 16)).overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1)) }.foregroundColor(.primary) }
} }
struct EditKnowledgePage: View { struct EditKnowledgePage: View {
@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() ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
VStack(spacing: 0) { ScrollView { VStack(spacing: 16) {
ZXBackHeader(title: "编辑知识点", subtitle: nil) {} 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)) }
ScrollView { VStack(spacing: 16) { 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); 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)) } Button { } label: { Text("保存修改").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) }
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)) } }.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80) }.scrollIndicators(.hidden) }
Button {} label: { Text("保存修改").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) } }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)}
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden)
}
}.navigationBarHidden(true)
}
} }

View File

@ -0,0 +1,92 @@
import SwiftUI
struct NotificationListView: View {
@State private var notifications: [NotificationItem] = [
.init(type: "review", title: "复习提醒", content: "你有 8 个知识点需要复习", time: "刚刚", read: false),
.init(type: "ai", title: "AI 分析完成", content: "\"机器学习基础\"薄弱点分析已完成", time: "1小时前", read: false),
.init(type: "streak", title: "学习成就", content: "恭喜!你已连续学习 14 天 🔥", time: "昨天", read: true),
.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 {
ZStack {
Color.zxBg0.ignoresSafeArea()
ScrollView {
VStack(spacing: 0) {
if notifications.isEmpty {
VStack(spacing: 12) {
Image(systemName: "bell.slash").font(.system(size: 40)).foregroundColor(Color.zxF03)
Text("暂无通知").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF03)
}.padding(.top, 120)
} else {
ForEach(Array(notifications.enumerated()), id: \.offset) { i, n in
ZXNotificationRow(item: n) {
notifications[i].read = true
}
if i < notifications.count - 1 {
ZXSettingDivider()
}
}
}
}
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
}.scrollIndicators(.hidden)
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)
}
}
struct NotificationItem: Identifiable {
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 onTap: () -> Void
private var iconName: String {
switch item.type {
case "review": return "arrow.triangle.2.circlepath"
case "ai": return "sparkles"
case "streak": return "flame.fill"
default: return "bell.fill"
}
}
private var iconColor: Color {
switch item.type {
case "review": return Color.zxOrange
case "ai": return Color.zxPurple
case "streak": return Color.zxGreen
default: return Color.zxAccent
}
}
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
Image(systemName: iconName).font(.system(size: 16)).foregroundColor(iconColor)
.frame(width: 36, height: 36).background(iconColor.opacity(0.12)).clipShape(RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(item.title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0)
if !item.read {
Circle().fill(Color.zxPurple).frame(width: 6, height: 6)
}
}
Text(item.content).font(.system(size: 12)).foregroundColor(Color.zxF04).lineLimit(2)
Text(item.time).font(.system(size: 10)).foregroundColor(Color.zxF03)
}
Spacer()
Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(Color.zxF03)
}.padding(.horizontal, 16).padding(.vertical, 14)
}.foregroundColor(.primary)
}
}

View File

@ -1,7 +1,3 @@
//
// ProfileView.swift - Page 10: Profile
//
import SwiftUI import SwiftUI
struct ProfileView: View { struct ProfileView: View {
@ -13,37 +9,53 @@ struct ProfileView: View {
HStack { HStack {
Text("我的").font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxF0).tracking(-0.5) Text("我的").font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxF0).tracking(-0.5)
Spacer() Spacer()
ZXIconBtn(icon: "bell", size: 36) {} NavigationLink(destination: NotificationListView()) {
ZXIconBtn(icon: "gearshape", size: 36) {} Image(systemName: "bell").font(.system(size: 18)).foregroundColor(Color.zxF05)
} .frame(width: 36, height: 36).background(Color(hex:"#FFFFFF",opacity:0.05))
.padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 4) .clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1))
}
NavigationLink(destination: SettingsView()) {
Image(systemName: "gearshape").font(.system(size: 18)).foregroundColor(Color.zxF05)
.frame(width: 36, height: 36).background(Color(hex:"#FFFFFF",opacity:0.05))
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1))
}
}.padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 4)
profileCard profileCard
VStack(spacing: 0) { VStack(spacing: 0) {
ZXProfileMenuRow(emoji: "🎯", title: "学习目标设置", desc: "调整你的学习目标") NavigationLink(destination: GoalSettingDetailView()) {
ZXProfileMenuRow(emoji: "🔔", title: "复习提醒", desc: "间隔复习通知设置") ZXProfileMenuRow(emoji: "🎯", title: "学习目标设置", desc: "调整你的学习目标")
ZXProfileMenuRow(emoji: "📊", title: "学习报告", desc: "周报 · 月报 · 成就") }.foregroundColor(.primary)
ZXProfileMenuRow(emoji: "🧩", title: "学习方法偏好", desc: "回忆 · 费曼 · 间隔") ZXProfileDivider()
ZXProfileMenuRow(emoji: "☁️", title: "数据同步与备份", desc: "云端同步设置") NavigationLink(destination: SettingsView()) {
} ZXProfileMenuRow(emoji: "🔔", title: "复习提醒", desc: "间隔复习通知设置")
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20)) }.foregroundColor(.primary)
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)) ZXProfileDivider()
NavigationLink(destination: MethodPreferenceView()) {
ZXProfileMenuRow(emoji: "🧩", title: "学习方法偏好", desc: "回忆 · 费曼 · 间隔")
}.foregroundColor(.primary)
ZXProfileDivider()
NavigationLink(destination: FeedbackFormView()) {
ZXProfileMenuRow(emoji: "💬", title: "帮助与反馈", desc: "问题报告 · 功能建议")
}.foregroundColor(.primary)
}.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20)).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
achievementsSection.padding(.bottom, 120) achievementsSection.padding(.bottom, 120)
} }.padding(.horizontal, 20)
.padding(.horizontal, 20) }.scrollIndicators(.hidden)
}
.scrollIndicators(.hidden)
} }
} }
private var profileCard: some View { private var profileCard: some View {
VStack(spacing: 16) { NavigationLink(destination: SettingsView()) {
HStack { VStack(spacing: 16) {
ZStack { Circle().frame(width: 80, height: 80).foregroundColor(Color.zxPurpleBG(0.2)); Text("🧑‍🎓").font(.system(size: 36)) } HStack {
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) } ZStack { Circle().frame(width: 80, height: 80).foregroundColor(Color.zxPurpleBG(0.2)); Text("🧑‍🎓").font(.system(size: 36)) }
Spacer(); Image(systemName: "chevron.right").font(.system(size: 14)).foregroundColor(Color.zxF03) 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) }
} 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: "14", label: "连续天", color: Color.zxOrange); ZXProfileStat(value: "47", label: "知识点", color: Color.zxPurple); ZXProfileStat(value: "1,240", 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)
} }
private var achievementsSection: some View { private var achievementsSection: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
@ -58,6 +70,9 @@ struct ZXProfileStat: View { let v: String; let l: String; let c: Color; var bod
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) }
} }
struct ZXProfileDivider: View {
var body: some View { Rectangle().fill(Color.zxBorder008).frame(height: 1).padding(.leading, 64) }
}
struct ZXAchievementBadge: View { let emoji: String; let label: String; let color: Color struct ZXAchievementBadge: View { let emoji: String; let label: String; let color: Color
var body: some View { VStack(spacing: 6) { Text(emoji).font(.system(size: 24)).frame(width: 48, height: 48).background(color.opacity(0.12)).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(color.opacity(0.25), lineWidth: 1)); Text(label).font(.system(size: 10, weight: .semibold)).foregroundColor(Color.zxF04) }.frame(maxWidth: .infinity) } var body: some View { VStack(spacing: 6) { Text(emoji).font(.system(size: 24)).frame(width: 48, height: 48).background(color.opacity(0.12)).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(color.opacity(0.25), lineWidth: 1)); Text(label).font(.system(size: 10, weight: .semibold)).foregroundColor(Color.zxF04) }.frame(maxWidth: .infinity) }
} }

View File

@ -0,0 +1,236 @@
import SwiftUI
struct SettingsView: View {
@State private var language = "zh-Hans"
@AppStorage("appAppearance") private var appearance = "system"
@State private var reviewReminder = true
@State private var reminderTime = "20:00"
@State private var intervalDays = "1"
@State private var iCloudSync = false
@State private var autoBackup = false
var body: some View {
ZStack {
Color.zxBg0.ignoresSafeArea()
ScrollView {
VStack(spacing: 16) {
sectionHeader("外观与语言")
VStack(spacing: 0) {
ZXSettingRow(title: "外观", value: appearanceLabel, icon: "moon.stars.fill", color: Color.zxPurple)
.contentShape(Rectangle())
.onTapGesture { toggleAppearance() }
ZXSettingDivider()
ZXSettingRow(title: "语言", value: "简体中文", icon: "globe", color: Color.zxTeal)
}
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
sectionHeader("学习设置")
VStack(spacing: 0) {
NavigationLink(destination: GoalSettingDetailView()) {
ZXSettingRow(title: "学习目标", value: "备考考试", icon: "target", color: Color.zxOrange)
}.foregroundColor(.primary)
ZXSettingDivider()
NavigationLink(destination: MethodPreferenceView()) {
ZXSettingRow(title: "学习方法偏好", value: "间隔回忆 · 费曼技巧", icon: "brain.head.profile", color: Color.zxPurple)
}.foregroundColor(.primary)
}
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
sectionHeader("复习提醒")
VStack(spacing: 0) {
ZXSettingToggleRow(title: "开启复习提醒", icon: "bell.badge.fill", color: Color.zxOrange, isOn: $reviewReminder)
if reviewReminder {
ZXSettingDivider()
ZXSettingPickerRow(title: "提醒时间", value: $reminderTime, options: ["08:00", "12:00", "18:00", "20:00", "21:00"])
ZXSettingDivider()
ZXSettingPickerRow(title: "间隔天数", value: $intervalDays, options: ["1", "2", "3", "5", "7"])
}
}
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
sectionHeader("数据")
VStack(spacing: 0) {
ZXSettingToggleRow(title: "iCloud 同步", icon: "icloud.fill", color: Color.zxTeal, isOn: $iCloudSync)
ZXSettingDivider()
ZXSettingToggleRow(title: "自动备份", icon: "arrow.triangle.2.circlepath", color: Color.zxAccent, isOn: $autoBackup)
}
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
VStack(spacing: 0) {
NavigationLink(destination: FeedbackFormView()) {
ZXSettingRow(title: "帮助与反馈", value: "", icon: "questionmark.circle.fill", color: Color.zxAccent)
}.foregroundColor(.primary)
ZXSettingDivider()
ZXSettingRow(title: "隐私政策", value: "", icon: "hand.raised.fill", color: Color.zxYellow)
ZXSettingDivider()
ZXSettingRow(title: "用户协议", value: "", icon: "doc.text.fill", color: Color.zxGreen)
}
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
HStack(spacing: 4) {
Text("知习 v1.0").font(.system(size: 12)).foregroundColor(Color.zxF03)
}.padding(.bottom, 100)
}.padding(.horizontal, 20).padding(.top, 8)
}
.scrollIndicators(.hidden)
}
.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)
}
private func sectionHeader(_ text: String) -> some View {
Text(text).font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5).padding(.top, 4)
}
private var appearanceLabel: String {
switch appearance { case "system": return "跟随系统"; case "dark": return "深色模式"; default: return "浅色模式" }
}
private func toggleAppearance() {
switch appearance {
case "system": appearance = "dark"
case "dark": appearance = "light"
default: appearance = "system"
}
}
}
struct GoalSettingDetailView: View {
@State private var selectedGoal = "备考考试"
let goals = [("🧑‍🎓","备考考试","公考、考研、考证等"),("💼","职业技能","编程、设计、产品等"),("📚","通识学习","扩充知识面"),("🎯","自定义","设定自己的目标")]
@State private var dailyMins = "30 分钟"
let times = ["15 分钟","30 分钟","1 小时","不限制"]
var body: some View {
ZStack {
Color.zxBg0.ignoresSafeArea()
ScrollView {
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 10) {
Text("学习目标").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035)
ForEach(goals, id: \.1) { g in let sel = selectedGoal == g.1
Button { selectedGoal = g.1 } label: {
HStack(spacing: 12) {
Text(g.0).font(.system(size: 22)).frame(width: 44, height: 44).background(sel ? Color(hex: "#7C6EFA", opacity: 0.15) : Color.zxFill005).clipShape(RoundedRectangle(cornerRadius: 12))
VStack(alignment: .leading, spacing: 2) { Text(g.1).font(.system(size: 15, weight: .semibold)).foregroundColor(sel ? Color.zxPurple : Color.zxF0); Text(g.2).font(.system(size: 12)).foregroundColor(Color.zxF04) }
Spacer()
Circle().stroke(sel ? Color.zxPurple : Color(hex: "#FFFFFF", opacity: 0.2), lineWidth: 2).frame(width: 22, height: 22).overlay { if sel { Circle().fill(Color.zxPurple).frame(width: 12, height: 12) } }
}.padding(14).background(sel ? Color(hex: "#7C6EFA", opacity: 0.08) : Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 16))
}.foregroundColor(.primary)
}
}
VStack(alignment: .leading, spacing: 10) {
Text("每日学习时间").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035)
HStack(spacing: 8) { ForEach(times, id: \.self) { t in let sel = dailyMins == t; Button { dailyMins = t } label: { Text(t).font(.system(size: 12)).fontWeight(sel ? .semibold : .regular).foregroundColor(sel ? Color.zxPurple : Color.zxF05).frame(maxWidth: .infinity).frame(height: 40).background(sel ? Color(hex: "#7C6EFA", opacity: 0.1) : Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 12)) }.foregroundColor(.primary) } }
}
Button {} label: {
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 MethodPreferenceView: View {
@State private var methods: Set<String> = ["间隔回忆", "费曼技巧"]
let allMethods = ["间隔回忆", "费曼技巧", "AI 分析", "主动回忆"]
var body: some View {
ZStack {
Color.zxBg0.ignoresSafeArea()
ScrollView {
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 10) {
Text("选择你偏好的学习方法").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035)
ForEach(allMethods, id: \.self) { m in let sel = methods.contains(m)
Button { if sel { methods.remove(m) } else { methods.insert(m) } } label: {
HStack(spacing: 12) {
Image(systemName: sel ? "checkmark.circle.fill" : "circle").font(.system(size: 20)).foregroundColor(sel ? Color.zxPurple : Color.zxF02)
Text(m).font(.system(size: 15, weight: .semibold)).foregroundColor(Color.zxF0)
Spacer()
}.padding(14).background(sel ? Color(hex: "#7C6EFA", opacity: 0.08) : Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 14))
}.foregroundColor(.primary)
}
}
Button {} label: {
Text("保存").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16))
}
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80)
}.scrollIndicators(.hidden)
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)
}
}
struct FeedbackFormView: View {
@State private var type = "功能建议"
@State private var content = ""
let types = ["Bug 反馈", "功能建议", "内容问题", "其他"]
var body: some View {
ZStack {
Color.zxBg0.ignoresSafeArea()
ScrollView {
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("反馈类型").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035)
HStack(spacing: 8) { ForEach(types, id: \.self) { t in let sel = type == t; Button { type = t } label: { Text(t).font(.system(size: 12)).foregroundColor(sel ? .white : Color.zxF05).padding(.horizontal, 12).padding(.vertical, 6).background(sel ? AnyView(ZXGradient.brandPurple) : AnyView(Color.zxFill005)).clipShape(Capsule()) } } }
}
VStack(alignment: .leading, spacing: 8) {
Text("内容").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035)
TextEditor(text: $content).frame(minHeight: 150).scrollContentBackground(.hidden).padding(12).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
}
Button {} label: {
Text("提交").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16))
}
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80)
}.scrollIndicators(.hidden)
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)
}
}
struct ZXSettingRow: View {
let title: String; let value: String; let icon: String; let color: Color
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon).font(.system(size: 16)).foregroundColor(color).frame(width: 32, height: 32).background(color.opacity(0.12)).clipShape(RoundedRectangle(cornerRadius: 8))
Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0)
Spacer()
if !value.isEmpty { Text(value).font(.system(size: 13)).foregroundColor(Color.zxF03) }
Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(Color.zxF03)
}.padding(.horizontal, 16).padding(.vertical, 14)
}
}
struct ZXSettingToggleRow: View {
let title: String; let icon: String; let color: Color; @Binding var isOn: Bool
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon).font(.system(size: 16)).foregroundColor(color).frame(width: 32, height: 32).background(color.opacity(0.12)).clipShape(RoundedRectangle(cornerRadius: 8))
Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0)
Spacer()
Toggle("", isOn: $isOn).labelsHidden().tint(Color.zxPurple)
}.padding(.horizontal, 16).padding(.vertical, 14)
}
}
struct ZXSettingPickerRow: View {
let title: String; @Binding var value: String; let options: [String]
var body: some View {
HStack(spacing: 12) {
Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0).opacity(0.6)
Spacer()
Picker(title, selection: $value) {
ForEach(options, id: \.self) { o in Text(o).tag(o) }
}.pickerStyle(.segmented).frame(width: 200).tint(Color.zxPurple)
}.padding(.horizontal, 16).padding(.vertical, 10)
}
}
struct ZXSettingDivider: View {
var body: some View { Rectangle().fill(Color.zxBorder008).frame(height: 1).padding(.leading, 60) }
}

View File

@ -0,0 +1,164 @@
import SwiftUI
struct LearningSessionView: View {
let taskTitle: String
let taskType: String
let taskColor: Color
@State private var elapsed: TimeInterval = 0
@State private var isRunning = true
@State private var isPaused = false
@State private var showEndConfirm = false
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
ZStack {
Color.zxBg0.ignoresSafeArea()
VStack(spacing: 0) {
ScrollView {
VStack(spacing: 20) {
timerCard
sessionInfoCard
tipsCard
}
.padding(.horizontal, 20)
.padding(.top, 8)
.padding(.bottom, 120)
}
.scrollIndicators(.hidden)
}
VStack { Spacer()
bottomBar
}.ignoresSafeArea(edges: .bottom)
}
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
.onReceive(timer) { _ in
if isRunning { elapsed += 1 }
}
.confirmationDialog("结束学习?", isPresented: $showEndConfirm, titleVisibility: .visible) {
Button("结束并保存", role: .destructive) { isRunning = false }
Button("继续学习", role: .cancel) {}
}
}
private var timerCard: some View {
VStack(spacing: 16) {
ZStack {
Circle()
.trim(from: 0, to: min(elapsed / 1800, 1))
.stroke(ZXGradient.brand, style: StrokeStyle(lineWidth: 8, lineCap: .round))
.rotationEffect(.degrees(-90))
.frame(width: 180, height: 180)
VStack(spacing: 4) {
Text(formatTime(elapsed))
.font(.system(size: 36, weight: .black))
.foregroundColor(Color.zxF0)
.tracking(-1)
Text("已学习")
.font(.system(size: 13, weight: .medium))
.foregroundColor(Color.zxF04)
}
}
HStack(spacing: 12) {
Button {
if isRunning { isPaused = true; isRunning = false }
else { isPaused = false; isRunning = true }
} label: {
Label(isRunning ? "暂停" : "继续", systemImage: isRunning ? "pause.fill" : "play.fill")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.white)
.frame(maxWidth: .infinity).frame(height: 48)
.background(ZXGradient.brandPurple)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
Button { showEndConfirm = true } label: {
Label("结束", systemImage: "stop.fill")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.zxF05)
.frame(maxWidth: .infinity).frame(height: 48)
.background(Color.zxFill005)
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 14))
}
}
}
.padding(24)
.background(ZXGradient.progressCard)
.overlay(RoundedRectangle(cornerRadius: 24).stroke(Color(hex: "#7C6EFA", opacity: 0.15), lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 24))
}
private var sessionInfoCard: some View {
VStack(spacing: 0) {
ZXSessionInfoRow(icon: "doc.text.fill", label: "当前任务", value: taskTitle, color: taskColor)
ZXSessionDivider()
ZXSessionInfoRow(icon: "tag.fill", label: "任务类型", value: taskType, color: taskColor)
ZXSessionDivider()
ZXSessionInfoRow(icon: "target", label: "建议时长", value: "30 分钟", color: Color(hex: "#7C6EFA"))
ZXSessionDivider()
ZXSessionInfoRow(icon: "chart.line.uptrend.xyaxis", label: "今日已学", value: "\(Int(elapsed / 60)) 分钟", color: Color.zxGreen)
}
.background(Color.zxFill003)
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1))
}
private var tipsCard: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Image(systemName: "lightbulb.fill").font(.system(size: 14)).foregroundColor(Color.zxYellow)
Text("学习小贴士").font(.system(size: 13, weight: .bold)).foregroundColor(Color.zxF0)
}
Text("保持专注25-30 分钟后休息 5 分钟能有效提升记忆效果。学习时尽量避免切换任务。")
.font(.system(size: 12)).foregroundColor(Color.zxF04).lineSpacing(4)
}
.padding(16)
.background(Color.zxFill004)
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 16))
}
private var bottomBar: some View {
HStack(spacing: 12) {
if isRunning {
HStack(spacing: 6) {
Circle().fill(Color.zxGreen).frame(width: 6, height: 6)
Text("学习中…").font(.system(size: 12, weight: .medium)).foregroundColor(Color.zxGreen)
}
} else if isPaused {
HStack(spacing: 6) {
Circle().fill(Color.zxYellow).frame(width: 6, height: 6)
Text("已暂停").font(.system(size: 12, weight: .medium)).foregroundColor(Color.zxYellow)
}
}
Spacer()
}
.padding(.horizontal, 24).padding(.vertical, 14)
.background(.ultraThinMaterial)
.background(Color.zxBg0.opacity(0.95))
.overlay(alignment: .top) { Rectangle().fill(Color.zxBorder008).frame(height: 1) }
}
private func formatTime(_ t: TimeInterval) -> String {
let m = Int(t) / 60, s = Int(t) % 60
return String(format: "%02d:%02d", m, s)
}
}
struct ZXSessionInfoRow: View {
let icon: String; let label: String; let value: String; let color: Color
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon).font(.system(size: 16)).foregroundColor(color)
.frame(width: 32, height: 32).background(color.opacity(0.12)).clipShape(RoundedRectangle(cornerRadius: 8))
Text(label).font(.system(size: 13, weight: .medium)).foregroundColor(Color.zxF04)
Spacer()
Text(value).font(.system(size: 13, weight: .semibold)).foregroundColor(Color.zxF0).lineLimit(1)
}.padding(.horizontal, 16).padding(.vertical, 14)
}
}
struct ZXSessionDivider: View {
var body: some View { Rectangle().fill(Color.zxBorder008).frame(height: 1).padding(.leading, 60) }
}

View File

@ -0,0 +1,159 @@
import SwiftUI
struct ReviewCardView: View {
let cards: [ReviewCardItem] = [
.init(question: "什么是偏差(Bias)和方差(Variance)的权衡?",
answer: "偏差衡量模型预测与真实值的偏离程度,方差衡量模型在不同训练集上的预测波动。偏差-方差权衡指的是:简单模型偏差高方差低(欠拟合),复杂模型偏差低方差高(过拟合)。最佳模型需要在两者之间取得平衡。",
source: "机器学习 · 偏差-方差权衡", count: 1, total: 8),
.init(question: "梯度下降中学习率(learning rate)的作用是什么?",
answer: "学习率控制每次参数更新的步长。太大的学习率会导致不收敛甚至发散太小的学习率会导致收敛速度过慢。通常从较大值开始逐步衰减或使用自适应学习率算法如Adam。",
source: "机器学习 · 梯度下降优化", count: 2, total: 8),
.init(question: "L1正则化和L2正则化有什么区别",
answer: "L1正则化(权重绝对值之和)倾向于产生稀疏解可用于特征选择L2正则化(权重平方和)倾向于让所有权重都接近零但不等於零,防止过拟合效果更好。",
source: "机器学习 · 正则化方法", count: 3, total: 8),
]
@State private var idx = 0
@State private var flipped = false
@State private var rating: Int? = nil
@State private var finish = false
var current: ReviewCardItem { cards[idx] }
var body: some View {
ZStack {
Color.zxBg0.ignoresSafeArea()
VStack(spacing: 0) {
progressBar
ScrollView {
VStack(spacing: 20) {
flashCard
if flipped { ratingBar }
}
.padding(.horizontal, 20)
.padding(.top, 12)
.padding(.bottom, 40)
}
.scrollIndicators(.hidden)
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
}
private var progressBar: some View {
VStack(spacing: 8) {
HStack {
Text("间隔复习 \(current.count)/\(current.total)")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.zxF04)
Spacer()
Text("剩余 \(current.total - current.count + 1)")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.zxPurple)
}
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2).fill(Color.zxFill008).frame(height: 3)
RoundedRectangle(cornerRadius: 2)
.fill(ZXGradient.progressBar)
.frame(width: max(3, CGFloat(current.count) / CGFloat(current.total) * (UIScreen.main.bounds.width - 40)), height: 3)
}
}
.padding(.horizontal, 20)
.padding(.top, 8)
.padding(.bottom, 4)
}
private var flashCard: some View {
VStack(spacing: 0) {
Text(flipped ? "答案" : "问题")
.font(.system(size: 10, weight: .bold))
.foregroundColor(flipped ? Color.zxGreen : Color.zxAccent)
.tracking(0.5)
.padding(.horizontal, 10).padding(.vertical, 3)
.background((flipped ? Color.zxGreen : Color.zxPurple).opacity(0.12))
.clipShape(Capsule())
.padding(.bottom, 16)
Text(flipped ? current.answer : current.question)
.font(.system(size: flipped ? 14 : 16, weight: flipped ? .medium : .semibold))
.foregroundColor(Color.zxF0)
.lineSpacing(6)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity, alignment: .center)
if flipped {
VStack(spacing: 6) {
Rectangle().fill(Color.zxBorder008).frame(height: 1).padding(.vertical, 12)
HStack(spacing: 4) {
Image(systemName: "book.closed.fill").font(.system(size: 10)).foregroundColor(Color.zxF03)
Text(current.source).font(.system(size: 11)).foregroundColor(Color.zxF04)
}
}
} else {
Text("点击翻转查看答案")
.font(.system(size: 11))
.foregroundColor(Color.zxF03)
.padding(.top, 20)
}
}
.padding(24)
.frame(minHeight: 240)
.background(flipped ? ZXGradient.progressCard : ZXGradient.thinkingCard)
.overlay(RoundedRectangle(cornerRadius: 20).stroke((flipped ? Color.zxPurple : Color.zxAccent).opacity(0.15), lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 20))
.onTapGesture { withAnimation(.easeInOut(duration: 0.4)) { flipped.toggle() } }
}
private var ratingBar: some View {
VStack(spacing: 10) {
Text("你的掌握程度?").font(.system(size: 13, weight: .semibold)).foregroundColor(Color.zxF04)
HStack(spacing: 10) {
ZXRatingBtn(label: "完全不会", color: Color.zxRed, selected: rating == 1) { rating = 1; nextCard() }
ZXRatingBtn(label: "有点难", color: Color.zxOrange, selected: rating == 2) { rating = 2; nextCard() }
ZXRatingBtn(label: "基本会", color: Color.zxYellow, selected: rating == 3) { rating = 3; nextCard() }
ZXRatingBtn(label: "很简单", color: Color.zxGreen, selected: rating == 4) { rating = 4; nextCard() }
}
HStack(spacing: 4) {
Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 10)).foregroundColor(Color.zxF03)
Text("AI 会根据你的评分自动安排下次复习时间").font(.system(size: 10)).foregroundColor(Color.zxF03)
}
}
}
private func nextCard() {
rating = nil
flipped = false
if idx < cards.count - 1 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { idx += 1 }
} else {
finish = true
}
}
}
struct ReviewCardItem {
let question: String
let answer: String
let source: String
let count: Int
let total: Int
}
struct ZXRatingBtn: View {
let label: String; let color: Color; let selected: Bool; let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: 4) {
Text(label).font(.system(size: 11, weight: selected ? .bold : .medium))
.foregroundColor(selected ? .white : Color.zxF05)
}
.frame(maxWidth: .infinity).frame(height: 56)
.background(selected ? AnyView(ZXGradient.brand) : AnyView(Color.zxFill005))
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay {
if !selected { RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1) }
}
}
}
}

View File

@ -1,7 +1,3 @@
//
// StudyHomeView.swift - Page 8
//
import SwiftUI import SwiftUI
struct StudyHomeView: View { struct StudyHomeView: View {
@ -23,7 +19,20 @@ struct StudyHomeView: View {
.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 ZXSTaskRow(task: t) { t.d.toggle() } } } ForEach($ts) { $t in
if t.tp == "回忆测试" {
NavigationLink(destination: ActiveRecallView()) { ZXSTaskRowView(task: t) { t.d.toggle() } }.foregroundColor(.primary)
} else if t.tp == "费曼练习" {
NavigationLink(destination: AIChatPage()) { ZXSTaskRowView(task: t) { t.d.toggle() } }.foregroundColor(.primary)
} else if t.tp == "薄弱点" {
NavigationLink(destination: WeakPointsPage()) { ZXSTaskRowView(task: t) { t.d.toggle() } }.foregroundColor(.primary)
} else if t.tp == "间隔复习" {
NavigationLink(destination: ReviewCardView()) { ZXSTaskRowView(task: t) { t.d.toggle() } }.foregroundColor(.primary)
} else {
NavigationLink(destination: LearningSessionView(taskTitle: t.t, taskType: t.tp, taskColor: t.c)) { ZXSTaskRowView(task: t) { t.d.toggle() } }.foregroundColor(.primary)
}
}
}
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: 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 { 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) } }
@ -40,11 +49,12 @@ struct StudyHomeView: View {
} }
struct ZXSTask: Identifiable { let id = UUID(); let t: String; let tp: String; let c: Color; let m: Int; var d: Bool } struct ZXSTask: Identifiable { let id = UUID(); let t: String; let tp: String; let c: Color; let m: Int; var d: Bool }
struct ZXSTaskRow: View { let task: ZXSTask; var action: () -> Void struct ZXSTaskRow: View { @Binding var task: ZXSTask
var body: some View { Button(action: action) { 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 { Button { task.d.toggle() } label: { ZXSTaskRowView(task: task) {} }.foregroundColor(.primary) }
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.zxF035) } } }
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)) } } struct ZXSTaskRowView: View { let task: ZXSTask; var action: () -> Void
.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) }.foregroundColor(.primary) } 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)) } }
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() } }
} }
extension Color { static let zxF035 = Color(hex: "#F0F0FF", opacity: 0.35) }

View File

@ -0,0 +1,188 @@
/* Localizable.strings (Base = 中文简体) */
/* 所有 SwiftUI Text 自动以中文文案作为 key 查找此表。 */
/* 中文作为 Base 语言key 与 value 相同。 */
/* 添加新语言时,创建对应 .lproj/Localizable.strings 翻译文件即可。 */
/* 通用 */
"好的" = "好的";
"取消" = "取消";
"重试" = "重试";
"加载中…" = "加载中…";
"确认" = "确认";
"跳过" = "跳过";
"保存" = "保存";
"提交" = "提交";
"提交中…" = "提交中…";
"搜索" = "搜索";
/* Tab */
"AI" = "AI";
"知识库" = "知识库";
"学习" = "学习";
"分析" = "分析";
"我的" = "我的";
/* 登录 */
"知习" = "知习";
"更懂你,更会学。" = "更懂你,更会学。";
"使用 Apple 继续" = "使用 Apple 继续";
"登录即代表你同意《用户服务协议》和《隐私政策》" = "登录即代表你同意《用户服务协议》和《隐私政策》";
"跳过,进入演示模式" = "跳过,进入演示模式";
"已有账号?立即登录" = "已有账号?立即登录";
"用 AI 把知识库、主动回忆和间隔复习连接起来,\n从\"看过\"走向\"真正学会\"。" = "用 AI 把知识库、主动回忆和间隔复习连接起来,\n从\"看过\"走向\"真正学会\"。";
"用 AI 重新定义\n你的学习方式" = "用 AI 重新定义\n你的学习方式";
/* 引导 */
"开始使用" = "开始使用";
"开始学习" = "开始学习";
"下一步" = "下一步";
"设定你的学习目标" = "设定你的学习目标";
"学习目标" = "学习目标";
"学习方法" = "学习方法";
"每日学习时间" = "每日学习时间";
"15 分钟" = "15 分钟";
"30 分钟" = "30 分钟";
"1 小时" = "1 小时";
"不限制" = "不限制";
"备考考试" = "备考考试";
"职业技能" = "职业技能";
"通识学习" = "通识学习";
"自定义" = "自定义";
"输入知识" = "输入知识";
"主动输出" = "主动输出";
"AI 分析" = "AI 分析";
"掌握知识" = "掌握知识";
/* 学习主页 */
"学习工作台" = "学习工作台";
"今日任务" = "今日任务";
"今日进度" = "今日进度";
"本周学习活跃" = "本周学习活跃";
"14 天连续" = "14 天连续";
"已学" = "已学";
"剩余" = "剩余";
"掌握" = "掌握";
"个任务" = "个任务";
"分钟" = "分钟";
"AI 自动排期" = "AI 自动排期";
"间隔复习" = "间隔复习";
"费曼技巧" = "费曼技巧";
"主动回忆" = "主动回忆";
"发现知识薄弱点" = "发现知识薄弱点";
"用自己的话讲出来" = "用自己的话讲出来";
"基于间隔重复的智能复习" = "基于间隔重复的智能复习";
/* AI 对话 */
"AI 对话" = "AI 对话";
"AI 学习助手" = "AI 学习助手";
"问 AI 任何学习问题…" = "问 AI 任何学习问题…";
"发送消息" = "发送消息";
"AI 对话输入" = "AI 对话输入";
/* AI 反馈 */
"AI 反馈" = "AI 反馈";
"今日思考" = "今日思考";
"你的回答" = "你的回答";
"答对的部分" = "答对的部分";
"需要完善" = "需要完善";
"✨ 参考答案要点" = "✨ 参考答案要点";
"加入待巩固,安排间隔复习" = "加入待巩固,安排间隔复习";
"深入提问" = "深入提问";
"再来一题" = "再来一题";
"开始回答" = "开始回答";
"提交回答,获取 AI 反馈" = "提交回答,获取 AI 反馈";
/* 复习 */
"复习计划" = "复习计划";
"今天" = "今天";
"明天" = "明天";
"本周" = "本周";
"暂无复习任务" = "暂无复习任务";
"完成学习后 AI 会自动生成复习计划" = "完成学习后 AI 会自动生成复习计划";
"间隔重复" = "间隔重复";
"费曼" = "费曼";
"回忆" = "回忆";
"薄弱" = "薄弱";
/* 知识库 */
"创建知识库" = "创建知识库";
"创建新知识库" = "创建新知识库";
"知识库名称" = "知识库名称";
"添加知识点" = "添加知识点";
"知识点详情" = "知识点详情";
"编辑知识点" = "编辑知识点";
"导入资料" = "导入资料";
"搜索知识库或知识点…" = "搜索知识库或知识点…";
/* 反馈 */
"反馈" = "反馈";
"反馈已提交" = "反馈已提交";
"感谢你的反馈,我们会尽快处理。" = "感谢你的反馈,我们会尽快处理。";
"请描述你遇到的问题或建议…" = "请描述你遇到的问题或建议…";
"反馈类型" = "反馈类型";
"提交反馈" = "提交反馈";
"问题报告 · 功能建议" = "问题报告 · 功能建议";
/* 设置 */
"语言" = "语言";
"外观" = "外观";
"跟随系统" = "跟随系统";
"深色模式" = "深色模式";
"浅色模式" = "浅色模式";
"学习目标设置" = "学习目标设置";
"调整你的学习目标" = "调整你的学习目标";
"复习提醒" = "复习提醒";
"间隔复习通知设置" = "间隔复习通知设置";
"学习报告" = "学习报告";
"周报 · 月报 · 成就" = "周报 · 月报 · 成就";
"学习方法偏好" = "学习方法偏好";
"回忆 · 费曼 · 间隔" = "回忆 · 费曼 · 间隔";
"数据同步与备份" = "数据同步与备份";
"云端同步设置" = "云端同步设置";
"开启复习提醒" = "开启复习提醒";
"提醒时间" = "提醒时间";
"间隔天数" = "间隔天数";
"iCloud 同步" = "iCloud 同步";
"自动备份" = "自动备份";
/* 成就 */
"成就" = "成就";
"连续天" = "连续天";
"知识点" = "知识点";
"积分" = "积分";
"学习者" = "学习者";
"连续 14 天" = "连续 14 天";
"费曼达人" = "费曼达人";
"知识收藏家" = "知识收藏家";
"速学者" = "速学者";
/* 学习分析 */
"学习分析" = "学习分析";
"综合掌握" = "综合掌握";
"需要复习" = "需要复习";
"薄弱知识点" = "薄弱知识点";
"掌握度趋势" = "掌握度趋势";
"本周积分" = "本周积分";
"最近 AI 互动" = "最近 AI 互动";
"昨天" = "昨天";
"近 7 天" = "近 7 天";
/* 错误 */
"网络请求失败" = "网络请求失败";
"登录状态已失效" = "登录状态已失效";
"数据解析失败" = "数据解析失败";
"无效的请求地址" = "无效的请求地址";
"服务器返回错误" = "服务器返回错误";
"Token 已过期" = "Token 已过期";
"无法获取 Apple 登录凭证" = "无法获取 Apple 登录凭证";
"未获取到身份验证信息" = "未获取到身份验证信息";
/* 内容分类 */
"Bug 反馈" = "Bug 反馈";
"功能建议" = "功能建议";
"内容问题" = "内容问题";
"其他" = "其他";
"公考、考研、考证等" = "公考、考研、考证等";
"编程、设计、产品等" = "编程、设计、产品等";
"扩充知识面" = "扩充知识面";
"设定自己的目标" = "设定自己的目标";

View File

@ -0,0 +1,61 @@
import XCTest
@testable import AIStudyApp
final class AIChatViewModelTests: XCTestCase {
var vm: AIChatViewModel!
override func setUp() {
super.setUp()
vm = AIChatViewModel()
}
override func tearDown() {
vm = nil
super.tearDown()
}
func testInitialState_hasOneMessage() {
XCTAssertEqual(vm.messages.count, 1)
XCTAssertEqual(vm.messages.first?.role, .ai)
}
func testInitialState_inputIsEmpty() {
XCTAssertTrue(vm.inputText.isEmpty)
}
func testCanSend_falseWhenEmpty() {
vm.inputText = ""
XCTAssertFalse(vm.canSend)
}
func testCanSend_falseWhenWhitespaceOnly() {
vm.inputText = " "
XCTAssertFalse(vm.canSend)
}
func testCanSend_trueWhenHasContent() {
vm.inputText = "你好"
XCTAssertTrue(vm.canSend)
}
func testSend_appendsUserMessage() {
vm.inputText = "测试消息"
vm.send()
XCTAssertEqual(vm.messages.count, 2)
XCTAssertEqual(vm.messages.last?.role, .user)
XCTAssertEqual(vm.messages.last?.content, "测试消息")
}
func testSend_clearsInput() {
vm.inputText = "测试"
vm.send()
XCTAssertTrue(vm.inputText.isEmpty)
}
func testSend_setsIsSending() {
vm.inputText = "测试"
vm.send()
XCTAssertTrue(vm.isSending)
}
}

View File

@ -0,0 +1,44 @@
import XCTest
@testable import AIStudyApp
final class FileCacheTests: XCTestCase {
var cache: FileCache!
override func setUp() {
super.setUp()
cache = FileCache(suite: "test_cache_\(UUID().uuidString)")
}
override func tearDown() {
try? cache.clear()
cache = nil
super.tearDown()
}
func testSaveAndLoad_roundTrip() throws {
let items = ["a", "b", "c"]
try cache.save(items, forKey: "test")
let loaded: [String]? = try cache.load([String].self, forKey: "test")
XCTAssertEqual(loaded, items)
}
func testLoad_missingKeyReturnsNil() throws {
let result: [String]? = try cache.load([String].self, forKey: "never_saved")
XCTAssertNil(result)
}
func testRemove_clearsKey() throws {
try cache.save([1, 2, 3], forKey: "numbers")
try cache.remove(forKey: "numbers")
let result: [Int]? = try cache.load([Int].self, forKey: "numbers")
XCTAssertNil(result)
}
func testSave_overwritesExistingKey() throws {
try cache.save([1], forKey: "key")
try cache.save([1, 2, 3], forKey: "key")
let loaded: [Int]? = try cache.load([Int].self, forKey: "key")
XCTAssertEqual(loaded, [1, 2, 3])
}
}

View File

@ -0,0 +1,59 @@
import XCTest
@testable import AIStudyApp
final class ReviewPlanViewModelTests: XCTestCase {
var vm: ReviewPlanViewModel!
override func setUp() {
super.setUp()
vm = ReviewPlanViewModel()
}
override func tearDown() {
vm = nil
super.tearDown()
}
func testInitialState_hasTasks() {
XCTAssertFalse(vm.todayTasks.isEmpty)
XCTAssertFalse(vm.tomorrowTasks.isEmpty)
XCTAssertFalse(vm.weekTasks.isEmpty)
}
func testTotalCount_sumsAllTasks() {
let expected = vm.todayTasks.count + vm.tomorrowTasks.count + vm.weekTasks.count
XCTAssertEqual(vm.totalCount, expected)
}
func testToggleTask_changesStatusToCompleted() {
guard let task = vm.todayTasks.first(where: { $0.status == .pending }) else {
XCTFail("Expected at least one pending task")
return
}
vm.toggleTask(task)
let toggled = vm.todayTasks.first(where: { $0.id == task.id })
XCTAssertEqual(toggled?.status, .completed)
}
func testToggleTask_togglingBackChangesToPending() {
guard let task = vm.todayTasks.first(where: { $0.status == .completed }) else {
XCTFail("Expected at least one completed task")
return
}
vm.toggleTask(task)
let toggled = vm.todayTasks.first(where: { $0.id == task.id })
XCTAssertEqual(toggled?.status, .pending)
}
func testToggleTask_doesNothingForUnknownTask() {
let before = vm.totalCount
let fake = ReviewTask(
id: "non-existent", userId: "", lessonId: "",
sourceSessionId: "", reviewType: .recall,
scheduledAt: "", completedAt: nil, status: .pending
)
vm.toggleTask(fake)
XCTAssertEqual(vm.totalCount, before)
}
}

View File

@ -0,0 +1,60 @@
import XCTest
@testable import AIStudyApp
final class StudyHomeViewModelTests: XCTestCase {
var vm: StudyHomeViewModel!
override func setUp() {
super.setUp()
vm = StudyHomeViewModel()
}
override func tearDown() {
vm = nil
super.tearDown()
}
func testInitialState_hasFiveTasks() {
XCTAssertEqual(vm.tasks.count, 5)
}
func testInitialState_twoTasksDone() {
XCTAssertEqual(vm.doneCount, 2)
}
func testProgress_calculatesCorrectly() {
XCTAssertEqual(vm.progress, 0.4, accuracy: 0.01)
}
func testToggleTask_changesDoneCount() {
let task = vm.tasks.first(where: { !$0.d })!
vm.toggleTask(task)
XCTAssertEqual(vm.doneCount, 3)
}
func testToggleTask_togglingBackRestoresCount() {
let task = vm.tasks.first(where: { $0.d })!
vm.toggleTask(task)
XCTAssertEqual(vm.doneCount, 1)
}
func testDoneMinutes_sumsCompletedTasks() {
XCTAssertEqual(vm.doneMinutes, 25) // 10 + 15
}
func testRemainingMinutes_sumsPendingTasks() {
XCTAssertEqual(vm.remainingMinutes, 30) // 8 + 12 + 10
}
func testToggleTask_updatesProgress() {
let task = vm.tasks.first(where: { !$0.d })!
vm.toggleTask(task)
XCTAssertEqual(vm.progress, 0.6, accuracy: 0.01)
}
func testWeekActivity_hasSevenDays() {
XCTAssertEqual(vm.weekActivity.count, 7)
XCTAssertEqual(vm.dayLabels.count, 7)
}
}

51
AIStudyApp/Info.plist Normal file
View File

@ -0,0 +1,51 @@
<?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>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

668
AIStudyApp/docs/AI对话.md Normal file
View File

@ -0,0 +1,668 @@
# 缺失项与待补全方向
> 基于 v0.1 创业计划文档与当前 iOS 代码对比分析
> 整理时间2026-05-10
本文档系统性列出知习 iOS App 当前在架构、页面、功能、设计等方面的缺失项,并给出优先级建议。
---
## 一、架构层缺失
### 1.1 MVVM 分层
**现状**:全部代码写在 SwiftUI View 中,无任何 ViewModel/ObservableObject/@Published。grep 搜索 ViewModel、ObservableObject、@Published 均为零结果。
**缺失**
- 无 ViewModel 层,业务逻辑、状态管理、数据转换全部堆在 View 里
- 无 Model 层,数据结构通过 View 内的局部 struct 或硬编码数据隐式定义
- 代码不可测试,无法单独验证业务逻辑
**计划要求**`官网与技术基础.md` 第 5.3 节):
```
AIStudyApp/
├── Features/
│ ├── Onboarding/
│ │ ├── Views/ ← 当前有,但无 ViewModel/Model 子目录
│ │ ├── ViewModels/ ← 缺失
│ │ └── Models/ ← 缺失
```
### 1.2 Service 层
**现状**:无任何 Service 类AI 分析、学习记录、用户管理等概念没有对应的服务抽象。
**缺失**
| Service | 职责 | 涉及的计划数据实体 |
|---------|------|-------------------|
| AuthService | Apple 登录、Token 管理、会话维护 | User |
| LearningService | 学习记录 CRUD、进度追踪 | LearningSession |
| AIService | AI 分析请求代理、结果解析 | AIAnalysis |
| ReviewService | 复习任务生成、调度 | ReviewTask |
| KnowledgeService | 知识库/路径/课程查询 | KnowledgeBase, LearningPath, Lesson |
| FeedbackService | 用户反馈提交 | Feedback |
### 1.3 Repository 层
**现状**:零数据持久化,所有"数据"均为 View 中硬编码的 mock。
**缺失**
- 无数据访问抽象(未来可能切换 CoreData → API需要 Repository 隔离)
- 无本地缓存层
- 无网络数据源层
### 1.4 网络层
**现状**:无任何网络请求代码,无 APIClient无 URLSession 调用。
**缺失**
- APIClient封装 URLSession注入 baseURL、header、token
- APIEndpoint枚举化 API 路径,统一请求构建)
- APIError统一错误模型和处理
- 请求/响应拦截器日志、token 刷新)
- Mock 层(本地开发和 UI 预览用)
### 1.5 依赖注入
**现状**:无任何 DI 模式Service 和 ViewModel 尚未创建,暂时不存在注入问题。但需要在架构搭建时建立模式。
**建议**:初期使用构造函数注入 + `@EnvironmentObject`,避免引入第三方 DI 框架。
---
## 二、核心能力缺失
### 2.1 Sign in with Apple
**现状**`LoginPage` 有 UI手机号/邮箱/微信/Apple 入口),但 `AIStudyAppApp` 仅用 `@AppStorage("hasCompletedOnboarding")` 控制是否进入主界面,无实际认证。
**计划要求**:第一版登录方式仅为 Sign in with Apple`Demo与MVP.md` 第 5.2 节)。
**需实现**
- ASAuthorizationController 集成
- 获取 appleUserId、email、displayName
- 后端验证 identityToken
- Token 本地安全存储Keychain
- 登录状态管理
### 2.2 后端 API 对接
**现状**:所有页面为静态 UI无任何网络请求。
**计划定义的 P0 API**
- `POST /ai/analyze-learning-input` — AI 分析用户学习输入
- `POST /ai/chat` — AI 对话
- 用户/知识库/学习记录/反馈 CRUD
### 2.3 真实 AI 集成
**现状**AI 相关页面全为静态文本。
**需对接**
- 后端 AI Provider 抽象层MiniMax/DeepSeek/OpenAI 等)
- 结构化 JSON 输出解析
- AI 分析结果展示(掌握度评分、优缺点、建议)
- AI 对话流式响应
### 2.4 本地数据持久化
**现状**:零持久化实现。
**需实现**
- UserDefaults / @AppStorage(简单偏好)
- KeychainToken、敏感信息
- 后续可考虑 CoreData 或 SwiftData学习记录离线缓存
### 2.5 多语言本地化
**现状**:所有文案硬编码在 View 中,无 Localizable.xcstrings 文件。
**计划要求**`Demo与MVP.md` 第 6 节):
- 默认简体中文
- 预留英文
- App UI 文案使用本地化资源
**需实现**
- 创建 `Localizable.xcstrings`
- 将所有硬编码文案迁移为 `LocalizedStringKey`
- 支持语言切换
### 2.6 错误/加载/空状态处理
**现状**:无任何错误处理、加载态、空状态 UI。
**至少需要**
- 网络请求 loading 指示器
- 网络错误提示和重试按钮
- AI 分析中的等待状态
- 列表空状态(如知识库为空时的引导)
- 登录失败错误提示
---
## 三、页面层面差距
### 3.1 与计划页面对比
| 计划页面 | 计划优先级 | 当前状态 | 说明 |
|----------|-----------|---------|------|
| 启动页/欢迎页 | P1 | ✅ 已实现 | SplashPage + WelcomePage |
| 登录页 | P0 | ⚠️ 过度实现 | UI 包含计划不做的手机号/邮箱/微信登录 |
| 语言与偏好页 | P1 | ❌ 未实现 | 无页面 |
| 学习方向选择页 | P0 | ⚠️ 部分实现 | GoalSetupPage 有目标选择,但非学习方向选择 |
| 学习路径页 | P0 | ✅ 已实现 | LibraryDetailPage |
| 今日学习任务页 | P0 | ✅ 已实现 | StudyHomeView |
| 内容阅读页 | P0 | ✅ 已实现 | KnowledgeDetailPage |
| 主动回忆/笔记输入页 | P0 | ✅ 已实现 | DailyThinkingPage + RecallTestPage |
| AI 分析结果页 | P0 | ✅ 已实现 | AIFeedbackPage |
| AI 对话页 | P0 | ✅ 已实现 | AIChatPage |
| 复习计划页 | P0 | ❌ 未独立 | 仅在 StudyHomeView 任务列表中混合出现 |
| 学习进度页 | P1 | ✅ 已实现 | AnalysisHomeView |
| 设置页 | P1 | ⚠️ 部分实现 | ProfileView 有设置菜单,但功能入口为空 |
| 反馈页 | P1 | ❌ 未实现 | 无反馈收集入口 |
### 3.2 复习计划页P0 缺失)
**计划描述**:系统生成复习任务,用户查看待复习内容,按推荐时间安排学习。
**当前**:复习任务混在 StudyHomeView 的任务列表里(如"高数 - 间隔复习 8 题"),缺少独立的复习计划视图。
**需实现**
- 独立的 `ReviewPlanView`
- 按时间线展示待复习任务(今天、明天、本周)
- 复习项来源标注(哪个知识点的第几次复习)
- 复习完成状态追踪
### 3.3 反馈页P1 缺失)
**计划描述**App 内反馈入口,让内测用户提交问题和建议。
**需实现**
- 简洁的反馈表单(文本输入 + 分类选择)
- 提交到后端 `/feedback` 接口
- 确认提交状态页
### 3.4 等待名单入口
**计划**:官网 `/waitlist` 页面收集用户App 内也需要引导用户加入等待名单/申请内测。
**需考虑**:是否在 App 内嵌等待名单入口(如 Welcome 页或设置页)。
---
## 四、设计与交互差距
### 4.1 Tab 结构调整
**计划设计**4 个 Tab — 学习 | 知识库 | AI助手 | 我的
**当前实现**5 个 Tab — AI | 知识库 | 学习 | 分析 | 我的
**差异分析**
- 当前把"学习"和"分析"拆成了两个独立 Tab
- 计划把"AI助手"独立为一个 Tab当前 AI 已是独立 Tab
- "分析"在计划中属于"学习"Tab 下的子页面,不需要顶层 Tab
**建议**(两种方案):
- **方案 A**:完全对齐计划 → 合并学习和分析为一个 Tab保持 4 Tab
- **方案 B**:保留 5 Tab 结构 → 更新计划文档,论证"分析"独立为 Tab 的合理性(学习数据可视化、学习进度监控是独立价值)
### 4.2 登录流程简化
**计划要求**:仅 Sign in with Apple不做手机号/邮箱/微信登录。
**当前 UI**:包含 4 种登录方式入口。
**建议**:第一版简化为仅 Apple 登录按钮 + 跳过选项,移除手机号/邮箱/微信登录 UI。
### 4.3 深色模式
**现状**:所有页面用 `.preferredColorScheme(.dark)` 强制深色,未验证浅色模式。
**建议**:确认是否需要支持浅色模式。如果只做深色,在 DesignTokens 中声明 `colorScheme: .dark`
### 4.4 无障碍
**现状**:未考虑 VoiceOver、Dynamic Type、高对比度等无障碍需求。
**至少需做**
- 关键按钮添加 `.accessibilityLabel`
- 确保 Dynamic Type 下布局不破碎
- 重点页面 VoiceOver 测试
### 4.5 动效
**计划要求**`官网与技术基础.md` 第 6.3 节):
- P0页面过渡、按钮反馈、加载状态、AI 分析中状态、学习完成反馈
- P1今日任务卡片动效、进度条更新、AI 结果分块出现
**当前**:仅有基础 SwiftUI 隐式动画withAnimation未实现任何计划中的动效。
---
## 五、数据层缺失
### 5.1 Model 定义
**现状**:所有数据通过 View 内局部变量或硬编码定义,无独立 Model 文件。
**计划中定义的核心实体**`Demo与MVP.md`
```
User
├── id, appleUserId, displayName, email
├── preferredLanguage, createdAt, lastLoginAt, status
KnowledgeBase
├── id, title, description, language, targetUser
├── createdAt, updatedAt
LearningPath
├── id, knowledgeBaseId, title, description
├── estimatedDays, order
Lesson
├── id, pathId, title, content, objectives
├── keyPoints, recallQuestions, practicePrompt
├── order, estimatedMinutes
LearningSession
├── id, userId, lessonId
├── startedAt, endedAt, userInput
├── aiAnalysis, masteryScore, weakPoints
├── nextSuggestion, reviewAt
AIAnalysis
├── id, userId, sessionId
├── inputText, outputJson, masteryScore
├── weakPoints, suggestions
├── modelName, createdAt, costEstimate
ReviewTask
├── id, userId, lessonId, sourceSessionId
├── reviewType, scheduledAt, completedAt, status
Feedback
UserLearningProfile
```
**需实现**:在 `Features/*/Models/` 下创建对应的 Swift struct需 Codable、Identifiable
### 5.2 API Contract
**现状**:无 API 类型定义。
**建议**:参考计划中定义的 JSON 结构,先创建 Swift Model再定义 API 请求/响应类型Request/Response struct实现前后端类型同构。
### 5.3 数据流规范
**现状**View 直接持有 @State,无数据流管理。
**建议**
- ViewModel 持有 @Published 状态
- ViewModel 通过 Service 获取数据
- Service 通过 Repository 访问数据源
- View 通过 @StateObject / @ObservedObject 绑定 ViewModel
---
## 六、工程化缺失
### 6.1 大文件拆分
**当前问题**
| 文件 | 行数 | 包含内容 |
|------|------|----------|
| `AIStudyAppApp.swift` | ~187 | 5 个完整页面 + 多个子组件 |
| `DailyThinkingPage.swift` | ~200+ | 5 个页面 + 共享组件 |
| `LibrarySubpages.swift` | ~150+ | 6 个页面 + 组件 |
**建议**:每个页面一个文件,共享组件移到 `Shared/Components/`
### 6.2 共享组件管理
**现状**`ZXTabBar` 在 ContentView.swift`ZXBackHeader` 在 DailyThinkingPage.swift`ZXCardRow` 在 LibrarySubpages.swift散落各处。
**建议**:集中到 `Shared/Components/`,建立组件目录如:
```
Shared/Components/
├── ZXTabBar.swift
├── ZXBackHeader.swift
├── ZXAIInputBar.swift
├── ZXScoreBox.swift
├── ZXIconBtn.swift
├── ZXCardRow.swift
├── ZXChip.swift
├── ZXQuickAction.swift
└── ZXStatBadge.swift
```
### 6.3 测试
**现状**:无任何测试代码。
**至少需要**
- ViewModel 单元测试(当 ViewModel 创建后)
- Service 层单元测试Mock Repository
- 关键 UI 流程的 Snapshot 测试
### 6.4 CI/CD
**现状**:无。
**建议**(后续):
- GitHub Actions / Xcode Cloud 自动构建
- TestFlight 自动分发
### 6.5 崩溃监控与埋点
**现状**:无。
**建议**:接入 Firebase Crashlytics 或类似服务,至少在 TestFlight 阶段要有崩溃收集能力。
---
## 七、优先级建议
### P0 — 必须在接后端前完成
| 优先级 | 项目 | 理由 |
|--------|------|------|
| P0 | 创建 Model 层(所有数据实体) | 是 Service/ViewModel/API 的基础 |
| P0 | 创建 API Contract 类型定义 | 前后端对齐的前提 |
| P0 | 搭建 APIClient + APIEndpoint | 所有后端交互的唯一通道 |
| P0 | 实现 AuthService + Apple 登录 | 用户身份是学习记录的前提 |
| P0 | 简化登录页为纯 Apple 登录 | 对齐计划,减少不必要 UI |
| P0 | 实现复习计划独立页 | 计划标记 P0当前缺失 |
| P0 | 拆分大文件 | 降低后续修改的认知负担 |
| P0 | 集中共享组件 | 避免组件散落导致重复开发 |
| P0 | 添加加载/错误/空状态处理 | 真机使用的基本体验保障 |
### P1 — 与后端对接同步推进
| 优先级 | 项目 | 理由 |
|--------|------|------|
| P1 | 搭建 ViewModel 层(逐步迁移) | 架构分层,但不阻塞功能开发 |
| P1 | 搭建 Service 层 | 随 API 对接自然建立 |
| P1 | 实现本地化架构 | 越晚做返工越多 |
| P1 | 实现反馈页 | TestFlight 内测必须 |
| P1 | 实现 Keychain 存储 | Token 安全存储 |
### P2 — App Store 前完成
| 优先级 | 项目 | 理由 |
|--------|------|------|
| P2 | Repository 层 | 当需要本地缓存 + 网络切换时再做 |
| P2 | 动效补充 | 体验优化,不阻塞功能 |
| P2 | 无障碍适配 | App Store 审核加分项 |
| P2 | 测试 | 用户量增长后需要 |
| P2 | Tab 结构调整决策 | 需要更多用户反馈来决策 |
---
## 八、总结
当前 iOS 项目完成了 UI 层的全量搭建21 页),但缺少"能让产品真正运转"的架构底座和数据能力。核心矛盾是:
> UI 超前,架构滞后。页面能点,但无数据、无认证、无 AI、无服务。
接下来最合理的推进顺序:
```
1. 建 Model 层(夯实数据基础)
2. 搭网络层 + Apple 登录(接通身份和通信)
3. 后端 API 逐个对接AI 分析和学习记录最先)
4. 重构 ViewModel 层(顺带解决架构问题)
5. 补充缺失页面(复习计划、反馈)
6. 本地化 + 动效 + 测试(打磨)
```
一句话:**先把骨架搭对,再长肉。**
对,**登录第一版只保留苹果登录**,这是最正确的决策。
你整理的文档里也已经明确写了:当前 `LoginPage` 虽然有手机号、邮箱、微信、Apple 入口,但第一版计划要求只做 Sign in with Apple并且需要实现 Apple 授权、后端验证、Keychain 存 token 和登录状态管理。
文档后面也建议把登录页简化为 **仅 Apple 登录按钮 + 可选跳过入口**,移除手机号、邮箱、微信登录 UI。
---
# 登录页第一版最终方案
## 正式入口只保留
```text
Sign in with Apple
```
## 删除这些入口
```text
手机号登录
邮箱登录
微信登录
验证码登录
第三方微信登录
```
## “跳过”要不要保留?
我建议:
```text
开发环境可以保留
正式环境不要明显展示
```
也就是说:
```swift
#if DEBUG
Button("跳过,进入演示模式") { ... }
#endif
```
不要把“跳过”作为正式登录入口不然后面学习记录、知识库、AI 分析都会涉及匿名用户迁移,很麻烦。
---
# 登录流程应该这样设计
```text
打开 App
AppSession 检查 Keychain 里有没有 refreshToken
如果有 token调用 /auth/refresh 或 /users/me
成功:进入主界面
失败:进入登录页
用户点击 Sign in with Apple
iOS 获取 identityToken / authorizationCode / userIdentifier
POST /api/auth/apple
后端返回 accessToken / refreshToken / user
iOS 把 token 存到 Keychain
AppSession 更新 currentUser
判断 onboardingCompleted
未完成:进入首次引导 / 学习目标设置
已完成:进入主界面
```
---
# iOS 需要实现的文件
登录相关最少这些:
```text
Features/Auth/Views/LoginView.swift
Features/Auth/ViewModels/LoginViewModel.swift
Core/Services/AuthService.swift
Core/Services/AuthServiceProtocol.swift
Core/Storage/KeychainStore.swift
Core/Storage/TokenStore.swift
App/AppSession.swift
Core/Models/AuthModels.swift
Core/Models/User.swift
```
---
# 接口请求结构
iOS 登录成功后给后端:
```swift
struct AppleLoginRequest: Encodable {
let identityToken: String
let authorizationCode: String?
let userIdentifier: String
let fullName: AppleFullName?
let email: String?
}
struct AppleFullName: Encodable {
let givenName: String?
let familyName: String?
}
```
后端返回:
```swift
struct AuthResponse: Decodable {
let accessToken: String
let refreshToken: String
let expiresIn: Int
let user: User
}
```
Token 存储:
```text
accessToken → Keychain
refreshToken → Keychain
不要存 UserDefaults
```
---
# 给 Trae 的修改指令
你可以直接发这段:
```text
现在调整知习 iOS App 的登录方案。
第一版登录方式只保留 Sign in with Apple。
请删除登录页中的:
- 手机号登录
- 邮箱登录
- 微信登录
- 其他第三方登录入口
正式 UI 只保留:
- Sign in with Apple 按钮
- 简短产品说明
- 隐私政策 / 用户协议入口
开发环境可以保留一个 DEBUG-only 的“跳过,进入演示模式”按钮,但不能出现在 Release 版本。
请实现以下架构:
1. LoginView
- 只展示 Apple 登录按钮
- 点击后调用 LoginViewModel.loginWithApple()
- 支持 loading / error 状态
- 登录失败时展示错误提示
2. LoginViewModel
- @MainActor
- ObservableObject
- @Published isLoading
- @Published errorMessage
- 调用 AuthService 处理 Apple 登录
3. AuthService
- 集成 ASAuthorizationController
- 获取 identityToken
- 获取 authorizationCode
- 获取 userIdentifier
- 获取 email / fullName如果 Apple 返回
- 调用后端 POST /api/auth/apple
- 接收 accessToken / refreshToken / user
4. TokenStore / KeychainStore
- accessToken 存 Keychain
- refreshToken 存 Keychain
- 支持 save / load / clear
5. AppSession
- 管理全局登录状态
- currentUser
- isAuthenticated
- bootstrap()
- loginWithApple()
- logout()
- refreshSession()
6. App 启动逻辑
- 启动时先检查 Keychain 中是否存在 refreshToken
- 如果存在,调用后端 refresh 或 /users/me
- 成功后进入主界面
- 失败则清空 token 并进入登录页
7. 登录成功后的跳转
- 如果 user.onboardingCompleted == false进入首次使用引导 / 学习目标设置
- 如果 user.onboardingCompleted == true进入 MainTabView
8. 注意
- 不要再使用 @AppStorage("hasCompletedOnboarding") 单独决定是否进入主界面
- 登录状态必须由 AppSession + Keychain token 决定
- 不要把 token 存到 UserDefaults
- 不要在 View 里直接写网络请求
- 不要在 View 里直接处理 Apple 登录细节
- 不要改变其他主页面 UI
```
---
# 登录页内容建议
页面可以非常简单:
```text
知习
更懂你,更会学。
用 AI 把知识库、主动回忆和间隔复习连接起来,
从“看过”走向“真正学会”。
[ Sign in with Apple ]
登录即代表你同意《用户服务协议》和《隐私政策》
```
就够了。
第一版登录越简单越好,别再做多登录方式。

View File

@ -0,0 +1,139 @@
# 知习 ZhiXi — 项目架构
> 更新时间2026-05-10
## 一、项目结构
```
AIStudyApp/
├── AIStudyAppApp.swift # 应用入口 + 引导流程路由
│ ├── OnboardingFlowView # Splash → Welcome → Login → Onboarding → GoalSetup
│ ├── SplashPage # 启动页
│ ├── WelcomePage # 欢迎页
│ ├── LoginPage # 登录页
│ ├── OnboardingPage # 功能引导
│ └── GoalSetupPage # 学习目标设置
├── ContentView.swift # 5-Tab 主界面
│ ├── ZXTabBar # 自定义底部 Tab 栏
│ ├── ZXAIInputBar # AI 输入栏
│ ├── ZXScoreBox # 评分组件
│ └── ZXIconBtn # 图标按钮
├── Core/
│ └── DesignSystem/
│ └── DesignTokens.swift # 颜色/渐变/圆角/间距/字号/排版
├── Features/
│ ├── AI/
│ │ ├── AIHomeView.swift # AI 首页Tab 1
│ │ └── DailyThinkingPage.swift # 今日思考 + AIChat + RecallTest + WeakPoints + AIFeedback
│ │
│ ├── Library/
│ │ ├── LibraryHomeView.swift # 知识库首页Tab 2
│ │ └── LibrarySubpages.swift # Create/Detail/Add/Import/KnowledgeDetail/Edit
│ │
│ ├── Study/
│ │ └── StudyHomeView.swift # 学习工作台Tab 3
│ │
│ ├── Analysis/
│ │ └── AnalysisHomeView.swift # 学习分析Tab 4
│ │
│ └── Profile/
│ └── ProfileView.swift # 我的Tab 5
└── Assets.xcassets/ # 资源文件
```
## 二、导航架构
```
@main AIStudyAppApp
├── hasCompletedOnboarding == false
│ └── OnboardingFlowView
│ ├── step 0: SplashPage ──(2s)──→ step 1
│ ├── step 1: WelcomePage ──"开始使用"──→ step 2
│ │ ──"已有账号"──→ hasCompletedOnboarding = true
│ ├── step 2: LoginPage ──"登录"──→ step 3
│ │ ──"跳过"──→ hasCompletedOnboarding = true
│ ├── step 3: OnboardingPage ──"下一步"──→ step 4
│ └── step 4: GoalSetupPage ──"开始学习"──→ hasCompletedOnboarding = true
└── hasCompletedOnboarding == true
└── ContentView (5-Tab)
├── Tab "AI" → NavigationStack { AIHomeView }
│ ├── → DailyThinkingPage → AIFeedbackPageView
│ ├── → RecallTestPage
│ ├── → WeakPointsPage
│ └── → AIChatPage
├── Tab "知识库" → NavigationStack { LibraryHomeView }
│ ├── → LibraryDetailPage → KnowledgeDetailPage
│ ├── → CreateLibraryPage
│ ├── → AddKnowledgePage
│ ├── → ImportPage
│ └── → EditKnowledgePage
├── Tab "学习" → NavigationStack { StudyHomeView }
├── Tab "分析" → NavigationStack { AnalysisHomeView }
└── Tab "我的" → NavigationStack { ProfileView }
```
## 三、数据流(当前均为静态 Mock
所有页面目前使用 `@State` 管理的本地假数据,尚未接入真实后端:
| 数据 | 当前状态 |
|------|----------|
| 用户信息 | ProfileView 中硬编码 |
| 知识库列表 | LibraryHomeView 中硬编码 4 个 |
| 学习任务 | StudyHomeView 中硬编码 5 个 |
| AI 分析结果 | 各 AI 页面静态文本 |
| 学习统计 | AnalysisHomeView 中硬编码 |
| 登录状态 | @AppStorage 布尔值控制 |
## 四、技术栈
| 层 | 技术 | 备注 |
|----|------|------|
| 语言 | Swift | — |
| UI 框架 | SwiftUI | iOS 17+ |
| 架构模式 | 当前未分层View 内聚) | 计划 MVVM + Service + Repository |
| 设计系统 | 自定义 DesignTokens | 从 React 原型 1:1 提取 |
| 构建工具 | Xcode | — |
| 目标平台 | iPhone (iOS 17+) | 未做 iPad/Mac 适配 |
## 五、与计划架构的差异
计划文档(`官网与技术基础.md`)中定义的 iOS 目录结构:
```
计划架构 当前实现
───────────────────────────────── ─────────────────
App/AIStudyApp.swift AIStudyAppApp.swift ✅
App/AppConfig.swift 未实现 ❌
App/AppRouter.swift 未实现 ❌
Core/Network/ 未实现 ❌
Core/Auth/ 未实现 ❌
Core/Storage/ 未实现 ❌
Core/Localization/ 未实现 ❌
Core/DesignSystem/ DesignTokens.swift ✅ (部分)
Features/*/Views/ Features/*/ ✅
Features/*/ViewModels/ 未实现 ❌ (View 内聚状态)
Features/*/Models/ 未实现 ❌ (无独立 Model)
Shared/Components/ 分散在各 View 文件中 ⚠️
Shared/Extensions/ 仅 Color hex 扩展 ✅
Shared/Utils/ 未实现 ❌
Shared/Constants/ 未实现 ❌
Resources/Localizable.xcstrings 未实现 ❌
Resources/PreviewData/ 未实现 ❌
```
## 六、待重构项
1. **View 文件过大**`AIStudyAppApp.swift` 包含 5 个独立页面,应拆分到各自文件
2. **无 ViewModel 层**:所有状态和数据逻辑写在 View 中,需要抽离
3. **无 Model 层**:数据结构(如学习任务、知识库卡片)分散在 View 中用局部变量定义
4. **共享组件未集中**`ZXTabBar``ZXBackHeader` 等组件散落在不同文件中
5. **无网络层**:无 API Client、无 Auth Service、无 Storage 层
6. **无本地化**:所有文案硬编码,未使用 `LocalizedStringKey`

View File

@ -0,0 +1,157 @@
# AIStudyApp 现状与缺口分析 - 第一篇:现有资源盘点
> 生成日期2026-05-11
> 后端地址http://81.70.187.179:3001
---
## 一、项目文件结构
```
AIStudyApp/
├── AIStudyAppApp.swift # 应用入口,含 5 步 Onboarding 流程
├── ContentView.swift # 主 Tab 框架5 个 Tab + 自定义底部栏)
├── Core/
│ ├── DesignSystem/DesignTokens.swift # 颜色/渐变/间距/字体全局设计令牌
│ ├── Models/APIModels.swift # 20+ DTO 数据模型
│ ├── Network/
│ │ ├── APIClient.swift # 通用 HTTP 客户端actor, async/await
│ │ ├── APIConfig.swift # baseURL 配置
│ │ └── APIError.swift # 错误枚举(网络/服务端/解码/认证)
│ └── Services/APIService.swift # 8 个服务类15 个公开方法
├── Features/
│ ├── AI/
│ │ ├── AIHomeView.swift # AI 首页 + ZXQuickAction + ZXAIInteractionRow 组件
│ │ └── DailyThinkingPage.swift # 每日思考题 + RecallTestPage / WeakPointsPage /
│ │ # AIFeedbackPageView / AIChatPage 子页面
│ ├── Analysis/
│ │ └── AnalysisHomeView.swift # 学习分析页 + ZXChartView 折线图 + ZXWeakRow 薄弱点
│ ├── Library/
│ │ ├── LibraryHomeView.swift # 知识库列表首页
│ │ └── LibrarySubpages.swift # CreateLibraryPage / LibraryDetailPage /
│ │ # AddKnowledgePage / KnowledgeDetailPage /
│ │ # ImportPage / EditKnowledgePage
│ ├── Profile/
│ │ └── ProfileView.swift # 个人中心页
│ └── Study/
│ └── StudyHomeView.swift # 学习工作台 + 今日任务 + 周活跃柱状图
└── Info.plist # 手动管理ATS例外 / Bundle元数据等
```
---
## 二、5 个 Tab 页面清单
| Tab | 标签 | SF Symbol | View |
|-----|------|-----------|------|
| 1 | AI | `brain.head.profile` | AIHomeView |
| 2 | 知识库 | `books.vertical.fill` | LibraryHomeView |
| 3 | 学习 | `bolt.fill` | StudyHomeView |
| 4 | 分析 | `chart.bar.fill` | AnalysisHomeView |
| 5 | 我的 | `person.fill` | ProfileView |
---
## 三、所有页面/子页面总览(共 21 个)
### AI 模块1 主 + 4 子)
| 页面 | 数据来源 | 核心功能 |
|------|---------|---------|
| AIHomeView | 🔴硬编码 | API 状态检测、思考题卡片、快捷操作、互动记录、提问输入栏 |
| DailyThinkingPage | 🔴硬编码 | AI 思考题展示 + 回答提交 |
| RecallTestPage | 🔴硬编码 | 回忆测试输入 |
| WeakPointsPage | 🔴硬编码 | 薄弱知识点静态列表 |
| AIFeedbackPageView | 🔴硬编码 | AI 反馈评分 + 操作入口 |
| AIChatPage | 🔴硬编码 | AI 对话气泡界面 |
### 知识库模块1 主 + 6 子)
| 页面 | 数据来源 | 核心功能 |
|------|---------|---------|
| LibraryHomeView | 🔴硬编码 | 知识库列表 + 搜索框 + 创建入口 |
| CreateLibraryPage | 🔴静态 | 创建表单(名称+描述) |
| LibraryDetailPage | 🔴硬编码 | 知识点静态列表 |
| AddKnowledgePage | 🔴静态 | 添加知识点表单 |
| KnowledgeDetailPage | 🔴硬编码 | 知识点详情+标签+复习/费曼入口 |
| ImportPage | 🔴静态 | 导入方式选择(拍照/文件/链接/相册) |
| EditKnowledgePage | 🔴静态 | 编辑知识点表单 |
### 学习模块1 主)
| 页面 | 数据来源 | 核心功能 |
|------|---------|---------|
| StudyHomeView | 🔴硬编码 | 今日进度环、任务列表5个任务、本周活跃柱状图 |
### 分析模块1 主)
| 页面 | 数据来源 | 核心功能 |
|------|---------|---------|
| AnalysisHomeView | 🔴硬编码 | 4项统计徽章、掌握度7日折线图、薄弱知识点列表 |
### 个人中心1 主)
| 页面 | 数据来源 | 核心功能 |
|------|---------|---------|
| ProfileView | 🔴硬编码 | 个人卡片、菜单列表、成就徽章 |
### 启动流程5 步 Onboarding
| 步骤 | 页面 | 功能 |
|------|------|------|
| Step 0 | SplashPage | 品牌开屏2 秒自动跳转 |
| Step 1 | WelcomePage | 3 大功能介绍 |
| Step 2 | LoginPage | 手机号/邮箱 + 密码表单 + 微信/Apple 登录入口 |
| Step 3 | OnboardingPage | 4 步功能轮播 |
| Step 4 | GoalSetupPage | 学习目标/方法/每日时长选择 |
---
## 四、APIService 已封装方法15 个)
| 服务类 | 方法 | 接口 |
|--------|------|------|
| WaitlistService | `join(...)` | POST /waitlist |
| | `stats()` | GET /waitlist/stats |
| AuthService | `appleLogin(...)` | POST /auth/apple |
| | `logout()` | POST /auth/logout |
| UserService | `myProfile()` | GET /users/me |
| | `updateProfile(...)` | PATCH /users/me |
| KnowledgeBaseService | `list()` | GET /knowledge-bases |
| | `create(...)` | POST /knowledge-bases |
| | `detail(id:)` | GET /knowledge-bases/:id |
| KnowledgeItemService | `list(baseId:)` | GET /knowledge-items |
| | `detail(id:)` | GET /knowledge-items/:id |
| | `create(...)` | POST /knowledge-items |
| AIAnalysisService | `analyze(...)` | POST /ai-analysis |
| ActivityService | `summary()` | GET /activity/summary |
| ReviewService | `due()` | GET /reviews/due |
| FocusItemService | `list()` | GET /focus-items |
| FeedbackService | `submit(...)` | POST /feedback |
---
## 五、后端接口 vs App 覆盖对照表
| 后端模块 | 接口数 | App 覆盖 | 状态 |
|----------|--------|---------|------|
| System | 3 | 0 | ❌ 无 |
| Auth | 3 | 2Service 有View 未接) | 🔶 |
| Users | 3 | 2Service 有View 未接) | 🔶 |
| KnowledgeBase | 5 | 3Service 有View 未接) | 🔶 |
| KnowledgeItems | 4 | 3Service 有View 未接) | 🔶 |
| DocumentImport | 2 | 0 | ❌ 无 |
| LearningSession | 3 | 0 | ❌ 无 |
| ActiveRecall | 2 | 0 | ❌ 无 |
| AIAnalysis | 3 | 1Service 有View 未接) | 🔶 |
| FocusItems | 4 | 1Service 有View 未接) | 🔶 |
| Review | 2 | 1Service 有View 未接) | 🔶 |
| LearningActivity | 2 | 1Service 有View 未接) | 🔶 |
| Notifications | 2 | 0 | ❌ 无 |
| Feedback | 4 | 1Service 有View 未接) | 🔶 |
| Waitlist | 3 | 2Service 有View 未接) | 🔶 |
> 覆盖率Service 层 15/48 = 31%View 层实际接入 0/48 = 0%

View File

@ -0,0 +1,267 @@
# AIStudyApp 现状与缺口分析 - 第二篇:缺失功能与实施路线
> 接第一篇《现有资源盘点》
> 生成日期2026-05-11
---
## 一、优先级总览
```
P0核心闭环本周必须 4 项
P1数据接入下周 5 项
P2新页面/功能,后续) 5 项
P3体验增强优化期 5 项
```
---
## 二、P0 —— 核心学习闭环4 项)
### P0-1真实 Apple 登录流程
**现状:** LoginPage 是静态表单,没有调 APIToken 没有持久化。
**需要做:**
| 子任务 | 涉及文件 |
|--------|---------|
| 集成 `AuthenticationServices`,添加 `ASAuthorizationAppleIDButton` | LoginPage内嵌在 AIStudyAppApp.swift |
| 拿到 `identityToken` 后调用 `AuthService.appleLogin(...)` | LoginPage |
| 登录成功后用 `@AppStorage` 或 Keychain 存储 Token | APIClient |
| `@main` 启动时检查已有 Token跳过 Onboarding | AIStudyAppApp.swift |
| 处理登录失败/网络错误的 UI 提示 | LoginPage |
| 接入 `POST /auth/refresh` Token 自动刷新 | APIClient |
涉及接口:`POST /auth/apple``POST /auth/refresh``POST /auth/logout`
---
### P0-2知识库 + 知识点接入真实 API
**现状:** LibraryHomeView 硬编码 4 个知识库,子页面表单没有提交。
**需要做:**
| 子任务 | 涉及文件 |
|--------|---------|
| `LibraryHomeView``.task {}` 中调 `KnowledgeBaseService.list()` | LibraryHomeView.swift |
| 替换硬编码卡片为 `ForEach(bases)` 真实数据 | LibraryHomeView.swift |
| `CreateLibraryPage` 表单提交调 `KnowledgeBaseService.create(...)` | LibrarySubpages.swift |
| `LibraryDetailPage` 加载真实知识点列表 `KnowledgeItemService.list(baseId:)` | LibrarySubpages.swift |
| `AddKnowledgePage` 表单提交调 `KnowledgeItemService.create(...)` | LibrarySubpages.swift |
| `EditKnowledgePage` 提交调 `PATCH /knowledge-items/:id`APIService 需新增 update 方法) | LibrarySubpages.swift + APIService.swift |
| 增加 loading / empty / error 三种状态处理 | 各 Library 页面 |
涉及接口:`GET/POST /knowledge-bases``GET/POST/PATCH /knowledge-items`
---
### P0-3学习会话追踪
**现状:** StudyHomeView 的"今日任务"是静态列表,没有学习计时,没有调任何接口。
**需要做:**
| 子任务 | 涉及文件 / 新建文件 |
|--------|-------------------|
| 新建 `LearningSessionView.swift`:含计时器(`Timer.publish`+ 暂停/结束按钮 | **新文件** Features/Study/LearningSessionView.swift |
| 点击 StudyHomeView 任务 → push 到 LearningSessionView | StudyHomeView.swift |
| 入场调 `POST /learning-sessions`(传入 knowledgeBaseId | LearningSessionView.swift |
| 结束/暂停时调 `POST /learning-sessions/:id/end` | LearningSessionView.swift |
| APIService 新增 `LearningSessionService` | APIService.swift |
| APIModels 新增 `LearningSessionCreateRequest` / `LearningSessionResponse` | APIModels.swift |
涉及接口:`POST /learning-sessions``POST /learning-sessions/:id/end``GET /learning-sessions`
---
### P0-4间隔复习卡片
**现状:** 没有复习页面。后端 `GET /reviews/due` + `POST /reviews/:id/submit` 已就绪。
**需要做:**
| 子任务 | 新建文件 |
|--------|---------|
| 新建 `ReviewCardView.swift`:正面问题 → 点击翻转 → 显示答案 → 评分按钮 | **新文件** Features/Study/ReviewCardView.swift |
| 评分按钮Again(1) / Hard(2) / Good(3) / Easy(4),调 `POST /reviews/:id/submit` | ReviewCardView.swift |
| 复习入口放在 StudyHomeView "今日任务"区域顶部 | StudyHomeView.swift |
| 复习入口放在 AIHomeView 快捷操作中 | AIHomeView.swift |
| 到期卡片数为 0 时显示空状态"🎉 都复习完啦" | ReviewCardView.swift |
涉及接口:`GET /reviews/due``POST /reviews/:id/submit`
---
## 三、P1 —— 数据接入5 项)
### P1-1薄弱点 / AI 分析接真实数据
**现状:** AnalysisHomeView / WeakPointsPage 硬编码 3 条数据。
**需要做:**
| 子任务 | 涉及文件 |
|--------|---------|
| AnalysisHomeView `.task {}` 中调 `FocusItemService.list()` | AnalysisHomeView.swift |
| 替换硬编码 ZXWeakRow 为 `ForEach(focusItems)` | AnalysisHomeView.swift |
| RecallTestPage 提交回答时调 `AIAnalysisService.analyze(...)` | DailyThinkingPage.swift |
| AIFeedbackPageView 展示真实分析结果 | DailyThinkingPage.swift |
| APIModels 增补 FocusItem 字段对齐后端 | APIModels.swift |
涉及接口:`GET /focus-items``POST /ai-analysis``GET /ai-analysis/:id`
---
### P1-2StudyHomeView 数据真实化
**现状:** 进度环、任务列表、周活跃柱状图全是硬编码。
**需要做:**
| 子任务 | 涉及文件 |
|--------|---------|
| 调 `ActivityService.summary()` 获取真实统计数据 | StudyHomeView.swift |
| 进度环用真实 `totalMinutes` / `streakDays` | StudyHomeView.swift |
| 周活跃图调 `GET /activity/heatmap`APIService 需新增 heatmap 方法) | StudyHomeView.swift + APIService.swift |
| 今日任务从 `GET /reviews/due` + `GET /focus-items` 拼接 | StudyHomeView.swift |
涉及接口:`GET /activity/summary``GET /activity/heatmap`
---
### P1-3ProfileView 接入用户资料
**现状:** ProfileView 全部静态假数据(昵称"学习者"、假统计)。
**需要做:**
| 子任务 | 涉及文件 |
|--------|---------|
| `.task {}``UserService.myProfile()` | ProfileView.swift |
| 替换头像emoji → 真实 avatar URL / 默认头像) | ProfileView.swift |
| 替换昵称、邮箱、统计数字 | ProfileView.swift |
| 菜单项"学习目标设置"跳设置表单页 → `PATCH /users/me/preferences` | ProfileView.swift + 新 SettingsView |
涉及接口:`GET /users/me``PATCH /users/me``PATCH /users/me/preferences`
---
### P1-4通知中心页面
**现状:** 完全没有通知页面。
**需要做:**
| 子任务 | 新建/涉及文件 |
|--------|-------------|
| 新建 `NotificationListView.swift` | **新文件** Features/Profile/NotificationListView.swift |
| `.task {}``GET /notifications` | NotificationListView.swift |
| 列表项点击标记已读 `POST /notifications/:id/read` | NotificationListView.swift |
| ProfileView 右上角铃铛 badge 显示未读数 | ProfileView.swift |
| APIService 新增 `NotificationService` | APIService.swift |
涉及接口:`GET /notifications``POST /notifications/:id/read`
---
### P1-5反馈提交
**现状:** 没有反馈提交入口。
**需要做:**
| 子任务 | 涉及文件 |
|--------|---------|
| ProfileView 菜单加"帮助与反馈" → 跳反馈表单 | ProfileView.swift + 新 FeedbackView |
| 调 `FeedbackService.submit(...)` | 新 FeedbackView |
| 提交后显示"感谢反馈"提示 | 新 FeedbackView |
涉及接口:`POST /feedback`
---
## 四、P2 —— 新页面/功能5 项)
### P2-1文件导入真实接入
**现状:** ImportPage 只有 4 个静态按钮。
**需要做:** 接入 `PHPickerViewController`(相册选图)、`UIDocumentPickerViewController`文件选择、AVCaptureSession拍照上传后调 `POST /imports`,轮询 `GET /imports/:id/status`。APIService 新增 `DocumentImportService`
---
### P2-2全局搜索
**现状:** LibraryHomeView 有搜索框但无效。
**需要做:** 新建 `SearchView.swift`,调 `GET /knowledge-items?keyword=xxx`,支持搜索知识点/知识库/标签,展示搜索结果列表。
---
### P2-3设置页面完善
**现状:** ProfileView 5 个菜单项全是假的。
**需要做:** 每个菜单项对应一个设置表单页:学习目标、复习提醒时间、学习报告邮件、学习方法偏好(费曼/回忆/间隔/综合)、数据同步状态。
---
### P2-4主动回忆Active Recall流程
**现状:** RecallTestPage 只提交假的 AI 分析,没有调 `GET /active-recalls`
**需要做:** 新建 ActiveRecallView展示问题卡片 → 输入回答 → 调 `POST /active-recalls/:id/submit`
---
### P2-5Token 自动刷新与登录态管理
**现状:** Token 没有持久化,没有 refresh 逻辑。
**需要做:** Keychain 存储 accessToken + refreshTokenAPIClient 拦截 401 → 自动调 `POST /auth/refresh` → 重试原请求refresh 也失败 → 清 Token → 跳登录页。
---
## 五、P3 —— 体验增强5 项)
| # | 项目 | 说明 |
|---|------|------|
| P3-1 | 下拉刷新 | 所有列表页 `.refreshable {}` + 页码分页 |
| P3-2 | 加载/空/错误三态 | 每个数据加载页加 ProgressView / 空状态插图+文案 / 错误重试按钮 |
| P3-3 | 离线缓存 | 用 UserDefaults 或本地 JSON 缓存最近数据,断网可展示 |
| P3-4 | 深色模式 | 当前强制 `.dark`,需支持跟随系统 |
| P3-5 | 无障碍 | VoiceOver labels、Dynamic Type 适配、高对比度 |
---
## 六、实施建议顺序
```
第 1 周 ─ P0-1 登录 → P0-2 知识库CRUD → P0-3 学习会话
第 2 周 ─ P0-4 复习卡片 → P1-1 薄弱点/AI分析 → P1-2 StudyHomeView 真实化
第 3 周 ─ P1-3 ProfileView → P1-4 通知中心 → P1-5 反馈
第 4 周 ─ P2-1 文件导入 → P2-2 搜索 → P2-3 设置页
第 5 周 ─ P2-4 主动回忆 → P2-5 Token刷新
第 6 周 ─ P3 体验增强
```
---
## 七、后端接口未封装清单(需新增 Service 方法)
| 模块 | 后端口 | 未封装接口 |
|------|--------|----------|
| KnowledgeBase | PATCH/DELETE | update / delete |
| KnowledgeItems | PATCH/DELETE | update / delete |
| LearningSession | POST/GET | start / end / list |
| ActiveRecall | GET/POST | list / submit |
| AIAnalysis | GET | result / job status |
| Activity | GET | heatmap |
| Notifications | GET/POST | list / markRead |
| DocumentImport | POST/GET | create / status |
| Review | POST | submit |
| FocusItems | POST/PATCH | create / update / complete |
> 需新增约 15 个 Service 方法 + 对应 Request/Response DTO

235
AIStudyApp/docs/pages.md Normal file
View File

@ -0,0 +1,235 @@
# 知习 ZhiXi — 页面清单与功能说明
> 更新时间2026-05-10
## 项目概述
**知习 (ZhiXi)** 是一个 AI-first 系统化学习 iOS App使用 SwiftUI 构建,深色主题。当前已完成 22 个页面的 UI 层实现,覆盖从引导流程到主界面 5 个 Tab 的完整交互链路。
---
## 一、引导流程5 页)
所有引导页面定义在 `AIStudyAppApp.swift``OnboardingFlowView` 中,通过 `@AppStorage("hasCompletedOnboarding")` 控制显示。
### 1. SplashPage — 启动页
- **路由**`OnboardingFlowView` step 0
- **功能**:展示品牌 Logo、App 名称"知习"、副标题"AI-first 系统化学习"底部显示加载进度条2 秒后自动跳转 Welcome
- **视觉**:深色渐变背景 + 紫色/橙色光晕 + 品牌渐变图标
### 2. WelcomePage — 欢迎页
- **路由**`OnboardingFlowView` step 1
- **功能**展示产品三大核心功能主动回忆、费曼解释、AI 分析);提供"开始使用"和"已有账号?立即登录"两个入口
- **UI 组件**`FeatureRow`emoji + 标题 + 描述卡片)
### 3. LoginPage — 登录页
- **路由**`OnboardingFlowView` step 2
- **功能**:支持手机号(+86和邮箱两种登录方式切换密码输入可切换明文/密文;底部提供微信登录和 Apple 登录入口;包含"忘记密码"链接
- **UI 组件**`ZXInputField``SocialLoginBtn`
### 4. OnboardingPage — 功能引导页
- **路由**`OnboardingFlowView` step 3
- **功能**4 步滑动引导(输入知识 → 主动输出 → AI 分析 → 掌握知识),底部进度指示器,支持跳过
- **UI 组件**:步进圆点指示器
### 5. GoalSetupPage — 学习目标设置页
- **路由**`OnboardingFlowView` step 4
- **功能**:设置学习目标(备考考试/职业技能/通识学习/自定义)、选择学习方法(间隔回忆/费曼技巧/AI 分析、选择每日学习时间15 分钟 ~ 不限制)
- **UI 组件**目标选项卡片emoji + 标题 + 描述 + 单选圆点)、方法标签组、时间选择组
---
## 二、主界面 — 5 Tab 结构
`ContentView.swift` 实现底部 5 个 Tab 的导航AI、知识库、学习、分析、我的。
### Tab 栏组件
- **ZXTabBar**:自定义底部 TabBar包含选中态光晕动画、品牌紫色高亮
- **ZXAIInputBar**:复用的 AI 输入栏sparkles 图标 + 输入框 + 麦克风 + 发送按钮)
- **ZXIconBtn**:通用图标按钮(支持 branded 紫色渐变样式)
- **ZXScoreBox**:评分展示组件(分数 + 背景色 + 前景色,根据分数区间变色)
---
### Tab 1AI 助手 — AIHomeView
- **文件**`Features/AI/AIHomeView.swift`
- **功能**
- 今日思考题卡片(展示 AI 生成的思考题,点击进入回答)
- 快捷操作区(生成回忆测试、分析薄弱点、费曼解释练习、今日复习计划)
- 最近 AI 互动列表(费曼复习、薄弱点分析、回忆测试记录,含评分)
- AI 提问建议区(预设问题模板,点击可发送)
- 底部 AI 输入栏
- **子页面**
- `DailyThinkingPage` — 今日思考详情(见下方)
- `RecallTestPage` — 回忆测试
- `WeakPointsPage` — 薄弱点分析
- `AIChatPage` — AI 对话
- **UI 组件**`ZXQuickAction`emoji + 标签)、`ZXAIInteractionRow`(标签 + 标题 + 时间 + 评分)
#### AI 子页面(定义在 `Features/AI/DailyThinkingPage.swift`
| 页面 | 功能 |
|------|------|
| **DailyThinkingPage** | 展示思考题 + 文本输入框,用户写下回答后提交给 AI 评估 |
| **AIChatPage** | AI 对话页面,围绕当前知识库进行学习问答 |
| **RecallTestPage** | 回忆测试:展示题目,用户回忆并写下理解,提交验证 |
| **WeakPointsPage** | 薄弱知识点列表,每个知识点显示掌握分数、所属知识库、优先级 |
| **AIFeedbackPage** | AI 反馈页,展示 AI 对用户回答的评分、优缺点分析、改进建议 |
**共享 UI 组件**`ZXBackHeader` — 带返回按钮、标题、副标题、右侧操作区的通用导航头
---
### Tab 2知识库 — LibraryHomeView
- **文件**`Features/Library/LibraryHomeView.swift`
- **功能**
- 知识库列表(机器学习、高等数学、英语词汇、产品设计等),每个卡片展示 emoji、名称、描述、知识点数量、掌握度百分比、标签、最近学习时间
- 顶部搜索栏
- 右上角搜索和创建按钮
- 创建新知识库入口(虚线边框卡片)
- **UI 组件**`ZLibraryCard`emoji + 名称 + 描述 + 进度条 + 标签 + 统计)
#### 知识库子页面(定义在 `Features/Library/LibrarySubpages.swift`
| 页面 | 功能 |
|------|------|
| **CreateLibraryPage** | 创建新知识库:填写名称、描述 |
| **LibraryDetailPage** | 知识库详情:展示该知识库下所有知识点列表,每个知识点显示标题、描述、掌握状态(已掌握/学习中/待复习) |
| **AddKnowledgePage** | 添加知识点:填写标题、内容 |
| **KnowledgeDetailPage** | 知识点详情:展示完整内容、标签、复习/费曼解释按钮 |
| **ImportPage** | 导入资料 |
| **EditKnowledgePage** | 编辑知识点 |
**知识库卡片组件**`ZXCardRow` — emoji + 标题 + 描述 + 状态标签
---
### Tab 3学习工作台 — StudyHomeView
- **文件**`Features/Study/StudyHomeView.swift`
- **功能**
- 日期和问候语("周四1月16日"
- 连续学习天数徽章(🔥)
- 今日进度卡片:完成任务数/总任务数、进度百分比环形图、进度条、已学时间/剩余时间/掌握积分
- 今日任务列表(机器学习回忆测试、高数间隔复习、英语词汇复习等),每个任务可勾选完成,显示任务类型标签和预计时长
- 本周学习活跃柱状图(周一~周日,高亮当天)
- 总计学习时长和日均统计
- **数据模型**`ZXSTask`(标题、类型、颜色、时长、完成状态)
- **UI 组件**`ZXSTaskRow`(勾选框 + 任务信息 + 类型标签 + 时长 + 播放按钮)
---
### Tab 4学习分析 — AnalysisHomeView
- **文件**`Features/Analysis/AnalysisHomeView.swift`
- **功能**
- 顶部统计卡片行综合掌握度65%+8%、本周积分1,240、待巩固知识点数23、连续学习天数14
- 掌握度趋势折线图(近 7 天数据)
- 薄弱知识点列表(可导航至 WeakPointsPage
- AI 学习建议卡片
- 知识库掌握分布(各知识库掌握度进度条)
- **UI 组件**`ZXStatBadge`(图标 + 标签 + 数值 + 变化趋势)、`ZXChartView``ZXWeakRow`
---
### Tab 5我的 — ProfileView
- **文件**`Features/Profile/ProfileView.swift`
- **功能**
- 顶部导航栏(标题"我的" + 通知铃铛 + 设置齿轮)
- 个人信息卡片头像emoji、昵称"学习者"、邮箱、连续天数/知识点数/积分统计
- 设置菜单:学习目标设置、复习提醒、学习报告、学习方法偏好、数据同步与备份
- 成就展示区(连续 14 天、费曼达人、知识收藏家、速学者)
- **UI 组件**`ZXProfileStat``ZXProfileMenuRow`emoji + 标题 + 描述 + 箭头)、`ZXAchievementBadge`
---
## 三、设计系统
文件:`Core/DesignSystem/DesignTokens.swift`
### 颜色系统
| 类别 | Token | 值 |
|------|-------|-----|
| 主背景 | `Color.zxBg0` | `#0F0F1A` |
| 品牌紫 | `Color.zxPurple` | `#7C6EFA` |
| 品牌橙 | `Color.zxOrange` | `#F97316` |
| 文字主色 | `Color.zxF0` | `#F0F0FF` |
### 渐变系统 (`ZXGradient`)
- `page` — 页面背景渐变
- `brand` — 品牌紫橙渐变
- `brandPurple` — 紫色渐变
- `thinkingCard` — 思考卡片渐变
- `progressCard` — 进度卡片渐变
- `feedbackScore` — 反馈评分渐变
- `profileCard` — 个人信息卡片渐变
- `ctaButton` / `ctaPurple` — CTA 按钮渐变
### 间距系统 (`ZXSpacing`)
- `pageHPadding`: 20
- `statusBarH`: 44
- `tabBarH`: 83
### 尺寸系统 (`ZXSize`)
- 图标按钮: 36, 头像: 36/64/80
- 按钮高度: 42/52/56, 快捷操作: 72
### 排版系统 (`ZXFont`)
- `titleLarge`: 22pt heavy, -0.5 tracking
- `titleMedium`: 20pt heavy, -0.4 tracking
- `sectionTitle`: 15pt bold
- `body`: 13pt semibold
- `bodySmall`: 12pt medium
- `caption`: 10pt bold
---
## 四、页面状态总览
| # | 页面 | Tab | 文件 | 导航方式 | 状态 |
|---|------|-----|------|----------|------|
| 1 | Splash 启动页 | — | AIStudyAppApp.swift | 自动跳转 | ✅ |
| 2 | Welcome 欢迎页 | — | AIStudyAppApp.swift | 按钮跳转 | ✅ |
| 3 | Login 登录页 | — | AIStudyAppApp.swift | 按钮跳转 | ✅ |
| 4 | Onboarding 引导 | — | AIStudyAppApp.swift | 步进/跳过 | ✅ |
| 5 | GoalSetup 目标 | — | AIStudyAppApp.swift | 完成进入主界面 | ✅ |
| 6 | AIHome AI 首页 | AI | AIHomeView.swift | Tab 1 | ✅ |
| 7 | LibraryHome 知识库 | 知识库 | LibraryHomeView.swift | Tab 2 | ✅ |
| 8 | StudyHome 学习 | 学习 | StudyHomeView.swift | Tab 3 | ✅ |
| 9 | AnalysisHome 分析 | 分析 | AnalysisHomeView.swift | Tab 4 | ✅ |
| 10 | Profile 我的 | 我的 | ProfileView.swift | Tab 5 | ✅ |
| 11 | AIChat AI 对话 | AI | DailyThinkingPage.swift | NavigationLink | ✅ |
| 12 | DailyThinking 今日思考 | AI | DailyThinkingPage.swift | NavigationLink | ✅ |
| 13 | RecallTest 回忆测试 | AI | DailyThinkingPage.swift | NavigationLink | ✅ |
| 14 | WeakPoints 薄弱点 | AI | DailyThinkingPage.swift | NavigationLink | ✅ |
| 15 | AIFeedback AI 反馈 | AI | DailyThinkingPage.swift | NavigationLink | ✅ |
| 16 | CreateLibrary 创建知识库 | 知识库 | LibrarySubpages.swift | NavigationLink | ✅ |
| 17 | LibraryDetail 知识库详情 | 知识库 | LibrarySubpages.swift | NavigationLink | ✅ |
| 18 | AddKnowledge 添加知识点 | 知识库 | LibrarySubpages.swift | NavigationLink | ✅ |
| 19 | Import 导入资料 | 知识库 | LibrarySubpages.swift | NavigationLink | ✅ |
| 20 | KnowledgeDetail 知识点详情 | 知识库 | LibrarySubpages.swift | NavigationLink | ✅ |
| 21 | EditKnowledge 编辑知识点 | 知识库 | LibrarySubpages.swift | NavigationLink | ✅ |
---
## 五、当前未实现的功能
以下为产品计划中但当前 iOS 项目尚未实现的能力:
- Sign in with AppleUI 已做,实际认证逻辑未接入)
- 后端 API 对接(当前为纯本地 UI
- 真实 AI 分析(当前为静态展示)
- 数据持久化(无本地存储/云端同步)
- 多语言本地化架构
- Apple IAP 支付
- 推送通知
- 崩溃监控/数据埋点

View File

@ -0,0 +1,339 @@
# 个人开发者创业 v0.1 — iOS 相关需求整理
> 来源:`startup-plan/个人开发者创业 v0.1/` 各文档
> 整理时间2026-05-10
本文档从 v0.1 创业计划中提取所有与 iOS 客户端直接相关的内容,作为 iOS 开发的参考基线。
---
## 一、产品定位(来源:`0-项目总纲/项目总纲.md`
- 产品长期方向AI 驱动的系统化学习产品(知识库 + 笔记 + AI 学习教练 + 复习计划)
- 当前只做三件事:确定方向 → 做 14 天验证 Demo → 找第一批真实反馈
- 平台策略:**只做 iPhone**,不做 Android/iPad/Mac/Web 学习端
- 不做:完整平台、泛学习大而全、复杂后端、支付
### 三个候选方向
1. 公考申论 AI 学习教练
2. AI 工具学习知识库
3. 程序员/前端面试学习助手
---
## 二、第一版产品形态(来源:`2-Demo与MVP/Demo与MVP.md`
```
iPhone App + 官网基础页面 + 最小后端 + AI API
```
### MVP 核心学习闭环
```
注册/登录 → 选择学习方向 → 进入学习路径 → 阅读知识内容
→ 主动回忆/写笔记/写答案 → AI 分析 → 生成学习状态
→ 给出复习和下一步建议 → 进入下一次学习
```
### 第一版页面列表(计划 14 页)
| 优先级 | 页面 | 当前 iOS 实现 |
|--------|------|---------------|
| **P0** | 登录页 | `LoginPage` ✅ |
| **P0** | 学习方向选择页 | `GoalSetupPage` ✅ (部分) |
| **P0** | 学习路径页 | `LibraryHomeView` + `LibraryDetailPage` ✅ |
| **P0** | 今日学习任务页 | `StudyHomeView` ✅ |
| **P0** | 内容阅读页 | `KnowledgeDetailPage` ✅ |
| **P0** | 主动回忆/笔记输入页 | `DailyThinkingPage` + `RecallTestPage` ✅ |
| **P0** | AI 分析结果页 | `AIFeedbackPage` ✅ |
| **P0** | AI 对话页 | `AIChatPage` ✅ |
| **P0** | 复习计划页 | 未独立实现 ⚠️ (部分在 StudyHomeView) |
| **P1** | 学习进度页 | `AnalysisHomeView` ✅ |
| **P1** | 设置页 | `ProfileView` ✅ |
| **P1** | 反馈页 | 未实现 ❌ |
| **P1** | 启动页/欢迎页 | `SplashPage` + `WelcomePage` ✅ |
| **P1** | 语言与基础偏好页 | 未实现 ❌ |
### 底部 Tab 设计(计划)
```
学习 | 知识库 | AI助手 | 我的
```
当前 iOS 实现为 5 个 Tab`AI | 知识库 | 学习 | 分析 | 我的`(多了"分析"Tab将计划的 AI 助手拆分为独立的分析页)
---
## 三、账号体系(来源:`2-Demo与MVP/Demo与MVP.md`
- **第一版登录方式**Sign in with Apple
- **暂不做**微信登录、手机号登录、邮箱密码登录、Google 登录
> ⚠️ 当前 iOS `LoginPage` 包含了手机号/邮箱登录 + 微信/Apple 登录 UI与计划"A Sign in with Apple"的要求不完全一致。计划强调极简,实际 UI 做了更多登录方式入口。
### 用户身份模型(计划)
```
User
├── id
├── appleUserId
├── displayName
├── email
├── preferredLanguage
├── createdAt
├── lastLoginAt
└── status
```
---
## 四、知识库设计(来源:`2-Demo与MVP/Demo与MVP.md`
### 数据结构
```
KnowledgeBase → LearningPath → Module → Lesson
├── 正文内容
├── 学习目标
├── 重点概念
├── 主动回忆问题
├── 练习输入
└── AI 分析规则
```
### 第一版内容范围
只做一个小路径,例如"AI 工具入门 7 天路径",而不是大而全的知识库市场。
---
## 五、AI 能力需求(来源:`2-Demo与MVP/Demo与MVP.md`
### AI 三大核心职责
1. 分析用户输入
2. 判断用户当前学习状态
3. 给出下一步学习建议
### AI 分析维度
```
理解程度、要点覆盖、逻辑结构、表达清晰度、错误理解、遗漏内容、下一步建议
```
### AI 输出结构(计划 JSON Schema
```json
{
"masteryScore": 3,
"understandingLevel": "基本理解",
"summary": "用户能说出核心意思,但要点不够完整。",
"strengths": ["能识别主要问题", "表达比较清楚"],
"weakPoints": ["遗漏关键要点", "逻辑层次不够清晰"],
"suggestions": ["补充材料中的第二个要点", "回答时先概括问题,再展开原因"],
"reviewNeeded": true,
"nextAction": "建议明天复习本节,并重新回答主动回忆问题。"
}
```
### 掌握度评分0-5
```
0 = 没有作答/无法判断
1 = 基本没理解
2 = 理解较弱
3 = 基本理解
4 = 理解较好
5 = 掌握很好
```
### AI 对话页定位
只能围绕当前知识库和学习内容,不能做泛聊天。快捷问题预设:
- 帮我解释这一节
- 用更简单的话讲
- 给我举个例子
- 我哪里理解错了
- 帮我总结重点
- 生成一个复习问题
---
## 六、学习状态模型(来源:`2-Demo与MVP/Demo与MVP.md`
### 用户学习画像
```
UserLearningProfile
├── userId
├── currentKnowledgeBaseId
├── currentPathId
├── currentLessonId
├── overallLevel
├── weakPoints
├── strengths
├── recentMistakes
├── reviewQueue
├── learningStreak
└── updatedAt
```
### 单次学习记录
```
LearningSession
├── id
├── userId
├── lessonId
├── startedAt
├── endedAt
├── userInput
├── aiAnalysis
├── masteryScore
├── weakPoints
├── nextSuggestion
└── reviewAt
```
### 复习任务
```
ReviewTask
├── id
├── userId
├── lessonId
├── sourceSessionId
├── reviewType
├── scheduledAt
├── completedAt
└── status
```
---
## 七、UI 设计原则(来源:`2-Demo与MVP/Demo与MVP.md`
```
安静、清晰、克制、学习感、低干扰、Apple原生感、卡片式结构、适合长时间阅读
```
- 不做花哨视觉
- 不做复杂动画
- 不做社交信息流
- 不做游戏化过重设计
- 优先保证阅读体验
- 优先保证学习任务清晰
- 优先保证 AI 分析结果可理解
---
## 八、技术选型(来源:`3-官网与技术基础/官网与技术基础.md`
| 项目 | 计划选型 | 当前实现 |
|------|----------|----------|
| UI 框架 | SwiftUI | SwiftUI ✅ |
| 架构模式 | MVVM + Service + Repository | 无分层View 内聚)⚠️ |
| 设计规范 | Apple HIG | 深色主题 + 自定义 DesignTokens ✅ |
| 动效策略 | 轻量、有意义、服务学习体验 | 最小动效(仅基础过渡)⚠️ |
| 多语言 | 预留架构,中文默认 | 未实现 ❌ |
| 部署 | 4 核 4G 轻量云 + Nginx + Docker | 未接入 ❌ |
### 目录结构(计划 vs 实际)
计划定义了完整的分层目录App/Core/Features/Shared/Resources当前实现仅有 Features 和 DesignSystem缺少 Network、Auth、Storage、Localization、ViewModel、Model 等层。
### 第一版 iOS 不做(来自计划)
- 复杂动画系统
- iPad 专门布局 / Mac Catalyst / Watch App / Widget
- 离线完整知识库
- 复杂搜索
- 文件导入
- 推送通知
- 支付订阅
- 复杂自定义控件
---
## 九、数据实体汇总(来源:`2-Demo与MVP/Demo与MVP.md`
```
User
KnowledgeBase
LearningPath
Lesson
LearningSession
AIAnalysis
ReviewTask
Feedback
WaitlistEntry
```
---
## 十、核心 API 接口(来源:`3-官网与技术基础/官网与技术基础.md`
### POST /ai/analyze-learning-input
分析用户学习输入,返回掌握度评估。
### POST /ai/chat
AI 对话接口,限于当前知识库上下文。
### 后端模块P0
Auth → User → Knowledge → Learning → AI → Review → Feedback → Waitlist
---
## 十一、成功标准(来源:`2-Demo与MVP/Demo与MVP.md`
### 产品可用标准
- 用户能登录
- 用户能选择学习路径
- 用户能完成一节学习
- 用户能输入内容
- AI 能返回分析
- 系统能生成复习建议
- 用户知道下一步该干什么
### 验证成功标准
- 至少 10 个用户愿意试用
- 至少 3 个用户完整走完学习闭环
- 至少 3 条有效反馈
- 至少 1 个用户表示愿意继续用
- 至少 1 个用户表示未来愿意付费
---
## 十二、暂缓事项(来源:`99-暂缓事项/暂缓事项.md`
以下为 v0.1 明确不做、后续解冻的事项:
| 类别 | 暂缓内容 | 解冻条件 |
|------|----------|----------|
| 商业化 | Apple IAP、订阅、免费试用 | TestFlight 有真实用户 + 有人愿意付费 |
| 运营 | 社群、客服机器人、打卡活动 | 10+ 内测用户持续反馈 |
| 数据 | 完整埋点、留存分析、付费转化 | App Store MVP 准备上线 |
| 合规 | 公司注册、微信/支付宝、备案 | Apple 端稳定收入 |
| 多端 | iPad、Mac、Android、Web | iPhone 核心稳定 + 用户多端需求 |
---
## 十三、当前 iOS 实现与计划的差距
| 维度 | 计划要求 | 当前状态 | 差距 |
|------|----------|----------|------|
| 登录 | Sign in with Apple | 多种登录 UI | 需简化或实现 Apple 登录 |
| 架构 | MVVM + Service | View 内聚 | 需重构分层 |
| 多语言 | 架构预留 | 未实现 | 需添加本地化 |
| 后端对接 | REST API | 无 | 需接入 |
| AI 集成 | 真实 AI 分析 | 静态 Mock | 需接入 AI API |
| 数据持久化 | 本地缓存 + Keychain | 无 | 需实现 |
| Tab 设计 | 4 个 Tab | 5 个 Tab | 多了"分析"Tab |
| 反馈页 | P1 优先级 | 未实现 | 需添加 |
| 复习计划页 | P0 优先级 | 部分实现 | 需独立设计 |

View File

@ -0,0 +1,550 @@
# 知习 iOS 样式规范
> 基于现有 DesignTokens 与实际页面中反复出现的 UI 模式,归纳形成本规范。
> 后续创建新页面时,优先从本文档引用的 token 和组件中选择,保持一致的设计语言。
---
## 一、色彩系统
所有颜色定义在 `Core/DesignSystem/DesignTokens.swift`,命名前缀 `zx`(知习)。
### 1.1 背景
| Token | 色值 | 用途 |
|---|---|---|
| `zxBg0` | `#0F0F1A` | 页面基底(与 zxBg1 组成 page 渐变) |
| `zxBg1` | `#12122A` | page 渐变的第二色 |
| `zxBg2` | `#0A0A14` | 手机外壳装饰用,极少使用 |
| `zxBgSplash` | `#0D0D20` | 启动页专用 |
### 1.2 文字
所有文字色基于 `#F0F0FF`(近白紫调)变化透明度:
| Token | 透明度 | 用途 |
|---|---|---|
| `zxF0` | 100% | 标题、正文高亮 |
| `zxF007` | 70% | 次重要正文 |
| `zxF006` | 60% | — |
| `zxF05` | 50% | 次要信息 |
| `zxF0045` | 45% | — |
| `zxF04` | 40% | 辅助描述 |
| `zxF035` | 35% | 弱标签、灰色字段名 |
| `zxF03` | 30% | 占位符级别 |
| `zxF02` | 20% | 极弱(如未选中图标边框) |
### 1.3 品牌/语义色
| Token | 色值 | 含义 |
|---|---|---|
| `zxPurple` | `#7C6EFA` | 主品牌,选中态、标签、进度 |
| `zxAccent` | `#A78BFA` | 次要品牌AI 相关 |
| `zxOrange` | `#F97316` | 热度/连续天数/回忆 |
| `zxTeal` | `#2DD4BF` | 语言/词汇 |
| `zxCyan` | `#4ECDC4` | 进度条渐变 |
| `zxGreen` | `#34D399` | 已掌握、成功 |
| `zxYellow` | `#F59E0B` | 薄弱/警告/待复习 |
| `zxRed` | `#EF4444` | 错误/高优先级 |
**彩色半透明背景**用于标签、badge 底衬):
```swift
zxPurpleBG(0.12) // 紫色 12% 透明
zxOrangeBG(0.10) // 橙色 10% 透明
zxGreenBG(0.15) // 绿色 15% 透明
zxYellowBG(0.15) // 黄色 15% 透明
zxTealBG(0.10) // 青色 10% 透明
zxRedBG(0.15) // 红色 15% 透明
```
### 1.4 边框/分割线
| Token | 透明度 | 用途 |
|---|---|---|
| `zxBorder015` | 15% | 较明显边框 |
| `zxBorder01` | 10% | 标准边框、虚线框 |
| `zxBorder008` | 8% | 通用卡片边框 |
| `zxBorder006` | 6% | 弱边框、卡片分隔 |
| `zxBorder004` | 4% | 极弱边框 |
### 1.5 填充(半透明叠层)
| Token | 透明度 | 用途 |
|---|---|---|
| `zxFill01` | 10% | 图表柱状 |
| `zxFill008` | 8% | 进度条底色 |
| `zxFill006` | 6% | 列表图标底衬 |
| `zxFill005` | 5% | 按钮/选中行底衬 |
| `zxFill004` | 4% | 输入框/面板底色 |
| `zxFill003` | 3% | 卡片底色 |
---
## 二、渐变
所有渐变定义在 `ZXGradient` enum。
| 渐变 | 颜色 | 方向 | 用途 |
|---|---|---|---|
| `page` | `#0F0F1A → #12122A` | top→bottom | 所有主页面背景 |
| `splash` | `#0D0D20 → #0F0F1A → #130D20` | top→bottom | 启动页背景 |
| `brand` | `#7C6EFA → #F97316` | topLeading→bottomTrailing | CTA 按钮、播放按钮 |
| `brandPurple` | `#7C6EFA → #9B8BFF` | leading→trailing | 发送按钮、AI 气泡 |
| `ctaButton` | `#7C6EFA → #F97316` | topLeading→bottomTrailing | 同 brandCTA 语义 |
| `ctaPurple` | `#7C6EFA → #9B8BFF` | topLeading→bottomTrailing | 紫色 CTA创建/保存/提交) |
| `progressBar` | `#7C6EFA → #4ECDC4` | leading→trailing | 进度条 |
| `thinkingCard` | `#7C6EFA 8% → #F97316 4%` | topLeading→bottomTrailing | 思考卡片 |
| `progressCard` | `#7C6EFA 10% → #F97316 5%` | topLeading→bottomTrailing | 进度卡片 |
| `feedbackScore` | `#7C6EFA 12% → #34D399 6%` | topLeading→bottomTrailing | 反馈评分卡片 |
| `profileCard` | `#7C6EFA 15% → #F97316 8%` | topLeading→bottomTrailing | 个人页卡片 |
---
## 三、圆角
```swift
ZXRadius.xs = 2 // 进度指示器小圆点
ZXRadius.sm = 8 // 小图标
ZXRadius.md = 10 // 标准图标按钮
ZXRadius.lg = 12 // 标签/徽章
ZXRadius.xl = 14 // 卡片/输入框/行
ZXRadius.xl2 = 16 // 大卡片/对话框
ZXRadius.xl3 = 20 // 主要面板
ZXRadius.button = 12 // 标准按钮
ZXRadius.buttonLg = 18 // 大按钮CTA
ZXRadius.icon = 10 // 小图标
ZXRadius.iconLg = 12 // 中图标
ZXRadius.avatar = 13 // 头像/emoji 图标
```
**使用原则**
- 卡片统一 `14``20`
- 按钮统 `12``18`
- 输入框 `14`
- 标签 `Capsule()`(自动全圆角)
---
## 四、间距
```swift
ZXSpacing.ss = 2 // 字间距/tracking 用
ZXSpacing.xs = 4
ZXSpacing.sm = 6
ZXSpacing.md = 8 // 卡片内元素间距
ZXSpacing.lg = 10
ZXSpacing.xl = 12 // 行内元素间距
ZXSpacing.xl2 = 14
ZXSpacing.xl3 = 16 // 卡片间距
ZXSpacing.xl4 = 20 // 页面水平内边距
ZXSpacing.xl5 = 24
ZXSpacing.xl6 = 28
// 专用
ZXSpacing.pageHPadding = 20 // 所有页面两侧统一留白
ZXSpacing.statusBarH = 44 // 状态栏 + Dynamic Island
ZXSpacing.tabBarH = 83 // 底部 TabBar 总高
ZXSpacing.homeIndicatorH = 34 // Home Indicator
```
**页面结构公式**
```
header top padding = statusBarH + 16
每个 section 间距 = 1220
ScrollView bottom = 120有 TabBar/ 80100子页面
```
---
## 五、尺寸
```swift
// 按钮
ZXSize.iconBtn = 36 // 图标按钮ZXIconBtn 默认)
ZXSize.buttonH = 42 // 标准按钮高
ZXSize.buttonLgH = 52 // 大按钮高
ZXSize.buttonXlH = 56 // CTA 按钮高
ZXSize.sendBtn = 30 // 发送按钮
// 图标
ZXSize.iconSm = 14
ZXSize.iconMd = 16
ZXSize.iconLg = 18
ZXSize.tabIcon = 22 // TabBar 图标
ZXSize.listIcon = 40 // 列表图标
ZXSize.libraryIcon = 44 // 知识库卡片图标
// 头像
ZXSize.avatarSm = 36
ZXSize.avatarMd = 64
ZXSize.avatarLg = 80
// 其他
ZXSize.quickActionH = 72 // 快捷操作高度
ZXSize.inputH = 44 // 输入框高度
ZXSize.progressH = 5 // 进度条高度
ZXSize.scoreBox = 36 // 分数方块
ZXSize.weakBox = 40 // 薄弱点分数方块
ZXSize.topBar = 3 // 顶部装饰条
ZXSize.searchIconBtn = 36 // 搜索图标按钮
```
---
## 六、字体层级
```swift
// 使用方式: .font(.system(size: ZXFont.xxx.size, weight: ZXFont.xxx.weight))
titleLarge // 22pt, heavy, -0.5 → 页面主标题
titleMedium // 20pt, heavy, -0.4 → 二级标题
sectionTitle // 15pt, bold → 区域标题
subsectionTitle // 14pt, bold → 子标题
body // 13pt, semibold, 1.4 → 正文
bodySmall // 12pt, medium → 辅助信息
caption // 10pt, bold → 标签加粗
captionSmall // 10pt, regular → 标签常规
labelXs // 9pt, regular → 最小标签
score // 12pt, heavy → 分数
scoreLarge // 22pt, heavy, 1 → 大分数
date // 12pt, medium → 日期
description // 12pt, regular, 0.4 → 描述文字
```
**实际页面中常用的内联声明**(可直接使用,也可用 ZXFont 引用):
- 页面大标题:`.font(.system(size: 22, weight: .heavy)).tracking(-0.5)` `.foregroundColor(.zxF0)`
- 区域标题:`.font(.system(size: 15, weight: .bold)).foregroundColor(.zxF0)`
- 卡片标题:`.font(.system(size: 13, weight: .semibold)).foregroundColor(.zxF0)`
- 辅助文字:`.font(.system(size: 12)).foregroundColor(.zxF04)`
- 标签文字:`.font(.system(size: 10, weight: .semibold))`
- 大数值:`.font(.system(size: 26, weight: .black))`
---
## 七、共享组件目录
以下组件散落在各页面文件中,新页面应直接复用,禁止重复实现。
### 7.1 导航/布局
**ZXTabBar**`ContentView.swift:58`
5 个 tabAI / 知识库 / 学习 / 分析 / 我的),选中态紫色高亮 + 圆形背景扩散。
```swift
ZXTabBar(active: $selectedTab)
```
**ZXBackHeader**`DailyThinkingPage.swift:52`
子页面顶部返回栏,含标题、副标题、可选的右侧按钮。
```swift
ZXBackHeader(title: "标题", subtitle: "副标题") { trailingView }
ZXBackHeader(title: "标题", subtitle: nil, onBack: customBackAction) { ... }
```
### 7.2 按钮
**ZXIconBtn**`ContentView.swift:100`
标准 36pt 圆形图标按钮,可切换品牌渐变样式。
```swift
ZXIconBtn(icon: "bell", size: 36) { action }
ZXIconBtn(icon: "plus", size: 36, branded: true) { action } // 品牌渐变底
```
**ZXOutlineBtn**`DailyThinkingPage.swift:167`
44pt 高描边文字按钮。
```swift
ZXOutlineBtn(text: "深入提问")
```
### 7.3 列表/卡片行
**ZXCardRow**`LibrarySubpages.swift:40`
带 emoji 图标、标题、描述、状态标签的标准列表行。
```swift
ZXCardRow(emoji: "📝", title: "标题", desc: "描述", status: "已掌握", c: .zxGreen)
```
**ZXImportOption**`LibrarySubpages.swift:93`
带大图标 + 标题描述的导入选项行。
**ZXProfileMenuRow**`ProfileView.swift:58`
Profile 页菜单行emoji + 标题 + 描述 + 箭头。
**ReviewTaskRow**`ReviewPlanView.swift:82`
复习任务行,含完成勾选 + 复习类型标签 + 播放按钮。
**ZXAIInteractionRow**`AIHomeView.swift:138`
AI 互动记录行,含标签 + 时间 + 分数。
### 7.4 数据展示
**ZXScoreBox**`ContentView.swift:50`
36pt 方形分数格。
```swift
ZXScoreBox(score: 82, bg: .zxGreenBG(0.15), fg: .zxGreen)
```
**ZXStatBadge**`AnalysisHomeView.swift:74`
统计徽章,图标 + 数值 + 标签 + 趋势。
**ZXWeakRow**`AnalysisHomeView.swift:91`
薄弱知识点行,分数 + 标题 + 库名 + 优先级。
**ZXAchievementBadge**`ProfileView.swift:61`
成就徽章emoji + 标签。
**ZXProfileStat**`ProfileView.swift:55`
Profile 页三栏统计数字。
**ZXChartView**`AnalysisHomeView.swift:117`
折线图(掌握度趋势)。
### 7.5 输入
**ZXAIInputBar**`ContentView.swift:26`
AI 对话输入栏,含 sparkles 图标 + 文本框 + 麦克风 + 发送按钮。
### 7.6 标签
**ZXChip**`LibrarySubpages.swift:74`
Capsule 标签,彩色文字 + 半透明底色。
**复习类型标签**`ReviewTaskRow.swift:129`
4 种:间隔重复(紫) / 费曼(accent) / 回忆(橙) / 薄弱(黄)。
### 7.7 卡片装饰
**ZXQuickAction**`AIHomeView.swift:132`
72pt 高快捷操作按钮emoji + 文字。
**ZLibraryCard**`LibraryHomeView.swift:32`
知识库卡片,顶部渐变色条 + emoji + 名称 + 进度 + 标签。
### 7.8 状态组件
**ZXLoadingView**`Shared/Components/ZXLoadingView.swift`
全屏加载指示器,紫色 ProgressView + "加载中…" 文字。
**ZXShimmerList**`Shared/Components/ZXLoadingView.swift`
骨架屏列表,传入 `count` 生成占位卡片。
**ZXErrorView**`Shared/Components/ZXErrorView.swift`
全屏错误状态,黄色三角图标 + 消息 + 可选重试按钮。
**ZXErrorBanner**`Shared/Components/ZXErrorView.swift`
内联错误横幅,黄色背景 + 消息 + 关闭按钮。
**ZXEmptyView**`Shared/Components/ZXEmptyView.swift`
空状态视图,支持图标 + 标题 + 副标题 + 可选操作按钮。
### 7.9 其他
**FeatureRow**`Shared/Components/FeatureRow.swift`
Welcome 页功能介绍行。
---
## 八、页面布局模式
### 8.1 主 Tab 页(有底部 TabBar
```swift
struct SomeHomeView: View {
var body: some View {
ZStack {
ZXGradient.page.ignoresSafeArea() // ① 背景
VStack(spacing: 0) {
// ② Header
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("副标题").font(.system(size: 12)).foregroundColor(.zxF04)
Text("主标题").font(.system(size: 22, weight: .heavy))
.foregroundColor(.zxF0).tracking(-0.5)
}
Spacer()
// 右侧按钮/状态
}
.padding(.horizontal, 20)
.padding(.top, ZXSpacing.statusBarH + 16)
.padding(.bottom, 12)
// ③ 可滚动内容
ScrollView {
VStack(spacing: 16) {
// 卡片/列表...
}
.padding(.horizontal, 20)
.padding(.bottom, 120) // 为 TabBar 留空间
}
.scrollIndicators(.hidden)
}
}
.navigationBarHidden(true)
.preferredColorScheme(.dark)
}
}
```
### 8.2 子页面(有返回按钮,无 TabBar
```swift
struct SomeDetailPage: View {
var body: some View {
ZStack {
Color.zxBg0.ignoresSafeArea() // 纯色(非渐变)
VStack(spacing: 0) {
ZXBackHeader(title: "标题", subtitle: "副标题") {
// 右侧按钮
}
ScrollView {
VStack(spacing: 16) {
// 内容
}
.padding(.horizontal, 20)
.padding(.bottom, 80)
}
.scrollIndicators(.hidden)
}
}
.navigationBarHidden(true)
}
}
```
### 8.3 卡片通用写法
```swift
VStack(alignment: .leading, spacing: 12) {
// 卡片内容
}
.padding(16)
.background(Color.zxFill003) // 底色
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color.zxBorder006, lineWidth: 1) // 边框
)
.clipShape(RoundedRectangle(cornerRadius: 16)) // 裁剪
```
渐变背景卡片把 `.background(Color.zxFill003)` 换成对应的 `ZXGradient.xxx`
### 8.4 输入框通用写法
```swift
TextField("占位文字", text: $text)
.font(.system(size: 14))
.tint(.zxPurple)
.padding(.horizontal, 16)
.frame(height: 52)
.background(Color.zxFill004)
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(Color.zxBorder008, lineWidth: 1)
)
```
### 8.5 状态标签Capsule 形)
```swift
Text("标签文字")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(semanticColor)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(semanticColor.opacity(0.12))
.clipShape(Capsule())
```
---
## 九、设计决策与约束
### 9.1 全局
- 强制深色模式:`.preferredColorScheme(.dark)`
- 所有页面 `navigationBarHidden(true)`,使用自建导航
- 禁止使用系统默认蓝色、默认圆角、默认间距
### 9.2 颜色
- 文字统一用 `#F0F0FF` 加透明度,不要用 `Color.white`
- 不要直接写 `Color(hex: ...)` 内联,优先用现有 token确需新色值先加 token
- 语义色不要混用——紫色是品牌/AI橙色是热度/连续,绿色是完成,黄色是警告
### 9.3 字体
- 不使用系统默认字体大小(如 `.title``.headline`),一律显式 `.system(size:weight:)`
- 标题统一 tracking 负值(`-0.5``-0.4`
- 中文内容避免使用 `.bold` 以下的极细字体
### 9.4 布局
- 页面水平留白统一 `20pt`
- 卡片间距 `1220pt`
- 内容区底部留白至少 `80pt`(子页面)或 `120pt`(有 TabBar
- 所有 ScrollView 隐藏指示器:`.scrollIndicators(.hidden)`
### 9.5 组件
- 优先复用第七节的共享组件,不要内联重复 UI
- 新组件如通用性足够,应抽到 `Shared/` 目录
---
## 十、文件引用索引
| 规范项 | 定义文件 |
|---|---|
| 颜色/渐变/圆角/间距/尺寸/字体 | `Core/DesignSystem/DesignTokens.swift` |
| ZXTabBar | `Shared/Components/ZXTabBar.swift` |
| ZXIconBtn | `Shared/Components/ZXIconBtn.swift` |
| ZXScoreBox | `Shared/Components/ZXScoreBox.swift` |
| ZXAIInputBar | `Shared/Components/ZXAIInputBar.swift` |
| ZXBackHeader | `Shared/Components/ZXBackHeader.swift` |
| ZXOutlineBtn | `Shared/Components/ZXOutlineBtn.swift` |
| ZXQuickAction | `Shared/Components/ZXQuickAction.swift` |
| ZXAIInteractionRow | `Shared/Components/ZXAIInteractionRow.swift` |
| ZXCardRow | `Shared/Components/ZXCardRow.swift` |
| ZXChip | `Shared/Components/ZXChip.swift` |
| ZXImportOption | `Shared/Components/ZXImportOption.swift` |
| ZXWeakRow | `Shared/Components/ZXWeakRow.swift` |
| ZXStatBadge | `Shared/Components/ZXStatBadge.swift` |
| ZXProfileMenuRow | `Shared/Components/ZXProfileMenuRow.swift` |
| ZXProfileStat | `Shared/Components/ZXProfileStat.swift` |
| ZXAchievementBadge | `Shared/Components/ZXAchievementBadge.swift` |
| ZXChartView | `Shared/Components/ZXChartView.swift` |
| ZXSTaskRow + ZXSTask | `Shared/Components/ZXSTaskRow.swift` |
| FeatureRow | `Shared/Components/FeatureRow.swift` |
| ReviewTaskRow | `Shared/Components/ReviewTaskRow.swift` |
| ZXLoadingView / ZXShimmerList | `Shared/Components/ZXLoadingView.swift` |
| ZXErrorView / ZXErrorBanner | `Shared/Components/ZXErrorView.swift` |
| ZXEmptyView | `Shared/Components/ZXEmptyView.swift` |
| ZXLibraryCard | `Features/Library/LibraryHomeView.swift` |
| AIHomeView | `Features/AI/AIHomeView.swift` |
| DailyThinkingPage | `Features/AI/DailyThinkingPage.swift` |
| RecallTestPage | `Features/AI/RecallTestPage.swift` |
| WeakPointsPage | `Features/AI/WeakPointsPage.swift` |
| AIFeedbackPageView | `Features/AI/AIFeedbackPage.swift` |
| AIChatPage | `Features/AI/AIChatPage.swift` |
| LibraryHomeView | `Features/Library/LibraryHomeView.swift` |
| CreateLibraryPage | `Features/Library/CreateLibraryPage.swift` |
| LibraryDetailPage | `Features/Library/LibraryDetailPage.swift` |
| AddKnowledgePage | `Features/Library/AddKnowledgePage.swift` |
| KnowledgeDetailPage | `Features/Library/KnowledgeDetailPage.swift` |
| ImportPage | `Features/Library/ImportPage.swift` |
| EditKnowledgePage | `Features/Library/EditKnowledgePage.swift` |
| AnalysisHomeView | `Features/Analysis/AnalysisHomeView.swift` |
| ProfileView | `Features/Profile/ProfileView.swift` |
| ReviewPlanView | `Features/Review/ReviewPlanView.swift` |
| StudyHomeView | `Features/Study/StudyHomeView.swift` |
| SplashPage | `Features/Onboarding/SplashPage.swift` |
| WelcomePage | `Features/Onboarding/WelcomePage.swift` |
| OnboardingPage | `Features/Onboarding/OnboardingPage.swift` |
| GoalSetupPage | `Features/Onboarding/GoalSetupPage.swift` |
| AppRootView / OnboardingFlowView | `AIStudyAppApp.swift` |
| LoginView | `Features/Auth/Views/LoginView.swift` |

File diff suppressed because it is too large Load Diff