diff --git a/.gitignore b/.gitignore
index 1e7707e..e43b0f9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,57 +1 @@
-# Xcode
-build/
-*.xcuserstate
-*.xcworkspace/xcuserdata/
-*.xcuserdatad/
-**/*.xcuserstate
-**/xcuserdata/
-
-# CocoaPods
-Pods/
-Podfile.lock
-
-# Carthage
-Carthage/Build/
-
-# Swift Package Manager
-.swiftpm/
-
-# CocoaPods
-*.xcworkspace/Contents.xcworkspacedata
-
-# Xcode user data
-*.moved-aside
-DerivedData/
-
-# IDE
-.idea/
-.vscode/
-*.swp
-*.swo
-
-# OS
.DS_Store
-Thumbs.db
-
-# Environment files
-.env
-.env.local
-.env.*.local
-
-# Logs
-logs/
-*.log
-
-# Build products
-*.hmap
-*.ipa
-*.dSYM.zip
-*.dSYM
-
-# Mac
-.DS_Store
-
-# Test coverage
-coverage/
-*.gcov
-*.prof
diff --git a/AIStudyApp/AIStudyApp.xcodeproj/project.xcworkspace/xcuserdata/Admin1.xcuserdatad/UserInterfaceState.xcuserstate b/AIStudyApp/AIStudyApp.xcodeproj/project.xcworkspace/xcuserdata/Admin1.xcuserdatad/UserInterfaceState.xcuserstate
new file mode 100644
index 0000000..6f902bb
Binary files /dev/null and b/AIStudyApp/AIStudyApp.xcodeproj/project.xcworkspace/xcuserdata/Admin1.xcuserdatad/UserInterfaceState.xcuserstate differ
diff --git a/AIStudyApp/AIStudyApp.xcodeproj/xcuserdata/Admin1.xcuserdatad/xcschemes/xcschememanagement.plist b/AIStudyApp/AIStudyApp.xcodeproj/xcuserdata/Admin1.xcuserdatad/xcschemes/xcschememanagement.plist
new file mode 100644
index 0000000..9af546d
--- /dev/null
+++ b/AIStudyApp/AIStudyApp.xcodeproj/xcuserdata/Admin1.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -0,0 +1,14 @@
+
+
+
+
+ SchemeUserState
+
+ AIStudyApp.xcscheme_^#shared#^_
+
+ orderHint
+ 0
+
+
+
+
diff --git a/AIStudyApp/AIStudyApp/AIStudyAppApp.swift b/AIStudyApp/AIStudyApp/AIStudyAppApp.swift
index 85a108b..df7ab50 100644
--- a/AIStudyApp/AIStudyApp/AIStudyAppApp.swift
+++ b/AIStudyApp/AIStudyApp/AIStudyAppApp.swift
@@ -135,8 +135,8 @@ struct WelcomePage: View {
Text("用 AI 重新定义\n你的学习方式").font(.system(size: 32, weight: .heavy)).tracking(-0.8).lineSpacing(4)
VStack(spacing: 10) {
FeatureRow(icon: "brain.head.profile", title: "主动回忆", desc: "基于间隔重复的智能复习")
- FeatureRow(icon: "mic.fill", title: "费曼解释", desc: "用自己的话讲出来")
- FeatureRow(icon: "chart.bar.fill", title: "AI 分析", desc: "发现知识薄弱点")
+ FeatureRow(icon: "mic", title: "费曼解释", desc: "用自己的话讲出来")
+ FeatureRow(icon: "chart.bar", title: "AI 分析", desc: "发现知识薄弱点")
}
}
VStack(spacing: 12) {
@@ -183,8 +183,8 @@ struct LoginPage: View {
if let error = errorMessage {
HStack(spacing: 6) {
- Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 12))
- Text(error).font(.system(size: 13))
+ Image(systemName: "exclamationmark.triangle").font(.system(size: 12))
+ Text(error).font(.system(size: 14))
}
.foregroundColor(Color(hex: "#991B1B"))
.padding(.horizontal, 16).padding(.vertical, 10)
@@ -211,7 +211,7 @@ struct LoginPage: View {
isLoggingIn = false
errorMessage = nil
}
- .font(.system(size: 11))
+ .font(.system(size: 12))
.foregroundColor(.white.opacity(0.7))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -324,9 +324,9 @@ private func sha256Data(_ data: Data) -> Data {
// MARK: - Shared UI components
-struct ZXTabBtn: View { let t: String; let active: Bool; let a: () -> Void; var body: some View { Button(action: a) { Text(t).font(.system(size: 13, weight: .semibold)).foregroundColor(active ? .white : Color.zxF05).frame(maxWidth: .infinity).frame(height: 36).background(active ? AnyView(ZXGradient.brand) : AnyView(Color.clear)).clipShape(RoundedRectangle(cornerRadius: 9)) } } }
-struct ZXInputField: View { let placeholder: String; @Binding var text: String; var isSecure = false; var body: some View { HStack { if isSecure { SecureField(placeholder, text: $text) } else { TextField(placeholder, text: $text) } }.font(.system(size: 15)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 52).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 14)) } }
-struct SocialLoginBtn: View { let emoji: String; let text: String; let color: Color; let action: () -> Void; var body: some View { Button(action: action) { HStack(spacing: 10) { Text(emoji).font(.system(size: 18)); Text(text).font(.system(size: 11, weight: .medium)) }.foregroundColor(Color.zxF007).frame(maxWidth: .infinity).frame(height: 52).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 14)) } } }
+struct ZXTabBtn: View { let t: String; let active: Bool; let a: () -> Void; var body: some View { Button(action: a) { Text(t).font(.system(size: 14, 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: 16)).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: 12, 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)) } } }
// MARK: - Onboarding
@@ -371,7 +371,7 @@ struct AccountStatusView: View {
.foregroundColor(Color.zxF0)
Text(message)
- .font(.system(size: 15))
+ .font(.system(size: 16))
.foregroundColor(Color.zxF04)
.multilineTextAlignment(.center)
.lineSpacing(4)
@@ -381,7 +381,7 @@ struct AccountStatusView: View {
onBackToLogin()
} label: {
Text("返回首页")
- .font(.system(size: 15, weight: .semibold))
+ .font(.system(size: 16, weight: .semibold))
.foregroundColor(Color.zxOnPrimary)
.frame(width: 200, height: 48)
.background(ZXGradient.brand)
@@ -410,9 +410,9 @@ struct GoalSetupPage: View {
Text("设定你的学习目标").font(.system(size: 24, weight: .heavy)).tracking(-0.5).foregroundColor(Color.zxF0).padding(.bottom, 24)
ScrollView { VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 10) { Text("学习目标").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5)
- 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) } }
+ 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: 16, 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(methods, id: \.self) { m in let sel = selectedMethod == m; Button { selectedMethod = m } label: { Text(m).font(.system(size: 13)).fontWeight(sel ? .semibold : .regular).foregroundColor(sel ? Color.zxPurple : Color.zxF05).padding(.horizontal, 16).padding(.vertical, 10).background(sel ? Color(hex: "#7C6EFA", opacity: 0.1) : Color.zxFill003).overlay(RoundedRectangle(cornerRadius: 20).stroke(sel ? Color(hex: "#7C6EFA", opacity: 0.25) : Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20)) }.foregroundColor(.primary) } } }
+ HStack(spacing: 8) { ForEach(methods, id: \.self) { m in let sel = selectedMethod == m; Button { selectedMethod = m } label: { Text(m).font(.system(size: 14)).fontWeight(sel ? .semibold : .regular).foregroundColor(sel ? Color.zxPurple : Color.zxF05).padding(.horizontal, 16).padding(.vertical, 10).background(sel ? Color(hex: "#7C6EFA", opacity: 0.1) : Color.zxFill003).overlay(RoundedRectangle(cornerRadius: 20).stroke(sel ? Color(hex: "#7C6EFA", opacity: 0.25) : Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20)) }.foregroundColor(.primary) } } }
VStack(alignment: .leading, spacing: 10) { Text("每日学习时间").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5)
HStack(spacing: 8) { ForEach(times, id: \.self) { t in let sel = dailyMins == t; Button { dailyMins = t } label: { Text(t).font(.system(size: 12)).fontWeight(sel ? .semibold : .regular).foregroundColor(sel ? Color.zxPurple : Color.zxF05).frame(maxWidth: .infinity).frame(height: 40).background(sel ? Color(hex: "#7C6EFA", opacity: 0.1) : Color.zxFill003).overlay(RoundedRectangle(cornerRadius: 12).stroke(sel ? Color(hex: "#7C6EFA", opacity: 0.25) : Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 12)) }.foregroundColor(.primary) } } } }
Button { onComplete(true) } label: { Text("开始学习").font(.system(size: 16, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 56).background(ZXGradient.ctaButton).clipShape(RoundedRectangle(cornerRadius: 18)).shadow(color: Color(hex: "#7C6EFA", opacity: 0.4), radius: 20) }.padding(.top, 24).padding(.bottom, 32).padding(.horizontal, 20)
diff --git a/AIStudyApp/AIStudyApp/Assets.xcassets/Icons/icon-folder.imageset/Contents.json b/AIStudyApp/AIStudyApp/Assets.xcassets/Icons/icon-folder.imageset/Contents.json
new file mode 100644
index 0000000..cede290
--- /dev/null
+++ b/AIStudyApp/AIStudyApp/Assets.xcassets/Icons/icon-folder.imageset/Contents.json
@@ -0,0 +1 @@
+{"images":[{"filename":"icon-folder.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"template-rendering-intent":"template","preserves-vector-representation":true}}
diff --git a/AIStudyApp/AIStudyApp/Assets.xcassets/Icons/icon-folder.imageset/icon-folder.svg b/AIStudyApp/AIStudyApp/Assets.xcassets/Icons/icon-folder.imageset/icon-folder.svg
new file mode 100644
index 0000000..32d051b
--- /dev/null
+++ b/AIStudyApp/AIStudyApp/Assets.xcassets/Icons/icon-folder.imageset/icon-folder.svg
@@ -0,0 +1,19 @@
+
+
diff --git a/AIStudyApp/AIStudyApp/Assets.xcassets/Icons/icon-xmark.imageset/Contents.json b/AIStudyApp/AIStudyApp/Assets.xcassets/Icons/icon-xmark.imageset/Contents.json
new file mode 100644
index 0000000..aa71d2d
--- /dev/null
+++ b/AIStudyApp/AIStudyApp/Assets.xcassets/Icons/icon-xmark.imageset/Contents.json
@@ -0,0 +1 @@
+{"images":[{"filename":"icon-xmark.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"template-rendering-intent":"template","preserves-vector-representation":true}}
diff --git a/AIStudyApp/AIStudyApp/Assets.xcassets/Icons/icon-xmark.imageset/icon-xmark.svg b/AIStudyApp/AIStudyApp/Assets.xcassets/Icons/icon-xmark.imageset/icon-xmark.svg
new file mode 100644
index 0000000..40a873c
--- /dev/null
+++ b/AIStudyApp/AIStudyApp/Assets.xcassets/Icons/icon-xmark.imageset/icon-xmark.svg
@@ -0,0 +1,20 @@
+
+
diff --git a/AIStudyApp/AIStudyApp/ContentView.swift b/AIStudyApp/AIStudyApp/ContentView.swift
index 863a9ca..6756172 100644
--- a/AIStudyApp/AIStudyApp/ContentView.swift
+++ b/AIStudyApp/AIStudyApp/ContentView.swift
@@ -1,47 +1,17 @@
import SwiftUI
import Combine
-// MARK: - TabBar visibility state
-
-class TabBarState: ObservableObject {
- @Published var isHidden = false
-}
-
-extension View {
- func hideTabBarWithAnimation() -> some View {
- modifier(HideTabBarModifier())
- }
-}
-
-struct HideTabBarModifier: ViewModifier {
- @EnvironmentObject private var tabBarState: TabBarState
-
- func body(content: Content) -> some View {
- content
- .onAppear {
- withAnimation(.easeInOut(duration: 0.28)) {
- tabBarState.isHidden = true
- }
- }
- .onDisappear {
- withAnimation(.easeInOut(duration: 0.28)) {
- tabBarState.isHidden = false
- }
- }
- }
-}
-
// MARK: - ContentView
struct ContentView: View {
@State private var selectedTab = "study"
- @StateObject private var tabBarState = TabBarState()
var body: some View {
TabView(selection: $selectedTab) {
NavigationStack {
- StudyHomeView()
+ StudyHomeView(selectedTab: $selectedTab)
.background(Color.zxCanvas.ignoresSafeArea())
+ .navigationDestination(for: Route.self) { $0.destination }
}
.tabItem {
Label("学习", image: selectedTab == "study" ? "tab-learn-active" : "tab-learn")
@@ -51,6 +21,7 @@ struct ContentView: View {
NavigationStack {
LibraryHomeView()
.background(Color.zxCanvas.ignoresSafeArea())
+ .navigationDestination(for: Route.self) { $0.destination }
}
.tabItem {
Label("知识库", image: selectedTab == "library" ? "tab-library-active" : "tab-library")
@@ -60,6 +31,7 @@ struct ContentView: View {
NavigationStack {
AnalysisHomeView()
.background(Color.zxCanvas.ignoresSafeArea())
+ .navigationDestination(for: Route.self) { $0.destination }
}
.tabItem {
Label("分析", image: selectedTab == "analysis" ? "tab-analysis-active" : "tab-analysis")
@@ -69,16 +41,14 @@ struct ContentView: View {
NavigationStack {
ProfileView()
.background(Color.zxCanvas.ignoresSafeArea())
+ .navigationDestination(for: Route.self) { $0.destination }
}
.tabItem {
Label("我的", image: selectedTab == "profile" ? "tab-profile-active" : "tab-profile")
}
.tag("profile")
}
- .environmentObject(tabBarState)
.tint(Color.zxPrimary)
- .toolbar(tabBarState.isHidden ? .hidden : .visible, for: .tabBar)
- .animation(.easeInOut(duration: 0.28), value: tabBarState.isHidden)
}
}
@@ -117,13 +87,13 @@ 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)
+ Text("\(score)").font(.system(size: 14, 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)
+ Text(topic).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0)
+ Text(lib).font(.system(size: 12)).foregroundColor(Color.zxF04)
}.frame(maxWidth: .infinity, alignment: .leading)
- Text("\(priority)优先").font(.system(size: 11, weight: .bold))
+ Text("\(priority)优先").font(.system(size: 12, 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())
@@ -142,7 +112,7 @@ struct ZXAIInputBar: View {
Image(systemName: "sparkles").font(.system(size: 16)).foregroundColor(Color.zxPurple)
TextField("问 AI 任何学习问题…", text: $text).font(.system(size: 14)).tint(Color.zxPurple).accessibilityLabel("AI 学习问题输入框")
Spacer()
- Image(systemName: "mic.fill").font(.system(size: 18)).foregroundColor(Color.zxF03).accessibilityLabel("语音输入")
+ Image(systemName: "mic").font(.system(size: 18)).foregroundColor(Color.zxF03).accessibilityLabel("语音输入")
Button(action: onSend) {
Image(systemName: "arrow.up").font(.system(size: 14, weight: .bold)).foregroundColor(.white)
.frame(width: 30, height: 30).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: 9))
diff --git a/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXAnimations.swift b/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXAnimations.swift
index 06e239e..e36993b 100644
--- a/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXAnimations.swift
+++ b/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXAnimations.swift
@@ -95,7 +95,7 @@ struct ZXThinkingOverlay: View {
VStack(spacing: 12) {
Text(message)
- .font(.system(size: 15, weight: .semibold))
+ .font(.system(size: 16, weight: .semibold))
.foregroundColor(.white)
if !reduceMotion { ZXDotLoader(color: .white) }
else { Text("处理中…").font(.system(size: 12)).foregroundColor(.white.opacity(0.7)) }
@@ -260,16 +260,16 @@ struct ZXAIAnalysisProgress: View {
if reduceMotion {
ProgressView().scaleEffect(1.5)
} else {
- ZXLoadingView(size: 48, lineWidth: 3)
+ ProgressView()
}
}
VStack(spacing: 4) {
Text("AI 分析中…")
- .font(.system(size: 17, weight: .bold))
+ .font(.system(size: 18, weight: .bold))
.foregroundColor(Color.zxF0)
Text(steps[safe: currentStep] ?? steps.last ?? "")
- .font(.system(size: 13))
+ .font(.system(size: 14))
.foregroundColor(Color.zxF04)
}
diff --git a/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXLoadingView.swift b/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXLoadingView.swift
index 0417bbe..3d34c3f 100644
--- a/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXLoadingView.swift
+++ b/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXLoadingView.swift
@@ -56,7 +56,7 @@ struct ZXLoadingOverlay: View {
ZStack {
Color.black.opacity(0.35).ignoresSafeArea()
VStack(spacing: 16) {
- ZXLoadingView(size: 44, lineWidth: 3.5)
+ ProgressView()
if let message {
Text(message)
.font(.system(size: 14, weight: .medium))
diff --git a/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXRefreshableScrollView.swift b/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXRefreshableScrollView.swift
index a5da092..116df1f 100644
--- a/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXRefreshableScrollView.swift
+++ b/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXRefreshableScrollView.swift
@@ -27,7 +27,7 @@ struct ZXRefreshableScrollView: View {
// Pull-to-refresh anchor
if isRefreshing {
VStack(spacing: 8) {
- ZXLoadingView(size: 28, lineWidth: 2.5)
+ ProgressView()
Text("刷新中…")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.zxF04)
@@ -61,13 +61,13 @@ struct ZXLoadMoreFooter: View {
var body: some View {
HStack(spacing: 10) {
if isLoading {
- ZXLoadingView(size: 20, lineWidth: 2)
+ ProgressView()
Text("加载中…")
- .font(.system(size: 13))
+ .font(.system(size: 14))
.foregroundColor(Color.zxF04)
} else {
Text("上拉加载更多")
- .font(.system(size: 13))
+ .font(.system(size: 14))
.foregroundColor(Color.zxF04)
}
}
@@ -92,9 +92,9 @@ struct ZXPullToRefreshModifier: ViewModifier {
VStack(spacing: 0) {
if isRefreshing {
HStack(spacing: 10) {
- ZXLoadingView(size: 22, lineWidth: 2)
+ ProgressView()
Text("正在刷新…")
- .font(.system(size: 13, weight: .medium))
+ .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.zxF04)
}
.frame(maxWidth: .infinity)
diff --git a/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXToast.swift b/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXToast.swift
index 1e7ab65..969a028 100644
--- a/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXToast.swift
+++ b/AIStudyApp/AIStudyApp/Core/DesignSystem/ZXToast.swift
@@ -8,9 +8,9 @@ enum ZXToastType {
var icon: String {
switch self {
- case .success: return "checkmark.circle.fill"
- case .error: return "xmark.circle.fill"
- case .warning: return "exclamationmark.triangle.fill"
+ case .success: return "checkmark.circle"
+ case .error: return "xmark.circle"
+ case .warning: return "exclamationmark.triangle"
case .info: return "info.circle.fill"
}
}
diff --git a/AIStudyApp/AIStudyApp/Core/Navigation/Route.swift b/AIStudyApp/AIStudyApp/Core/Navigation/Route.swift
index ed7cf70..d429591 100644
--- a/AIStudyApp/AIStudyApp/Core/Navigation/Route.swift
+++ b/AIStudyApp/AIStudyApp/Core/Navigation/Route.swift
@@ -60,7 +60,7 @@ extension Route {
case .learningSession(let title, let type, let colorHex):
LearningSessionView(taskTitle: title, taskType: type, taskColor: Color(hex: colorHex))
- case .studyHome: StudyHomeView()
+ case .studyHome: StudyHomeView(selectedTab: .constant("study"))
case .notificationList: NotificationListView()
case .settings: SettingsView()
diff --git a/AIStudyApp/AIStudyApp/Features/AI/AIChatPage.swift b/AIStudyApp/AIStudyApp/Features/AI/AIChatPage.swift
index 46e19b8..5e67a56 100644
--- a/AIStudyApp/AIStudyApp/Features/AI/AIChatPage.swift
+++ b/AIStudyApp/AIStudyApp/Features/AI/AIChatPage.swift
@@ -13,11 +13,11 @@ struct AIChatPage: View {
if vm.isCreatingSession {
VStack(spacing: 12) {
ProgressView().tint(Color.zxPurple)
- Text("正在准备 AI 助手...").font(.system(size: 13)).foregroundColor(Color.zxF04)
+ Text("正在准备 AI 助手...").font(.system(size: 14)).foregroundColor(Color.zxF04)
}.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = vm.sessionError {
VStack(spacing: 16) {
- Image("icon-warning").font(.system(size: 36)).foregroundColor(Color.zxF04)
+ Image(systemName: "exclamationmark.triangle").font(.system(size: 36)).foregroundColor(Color.zxF04)
Text(error).font(.system(size: 14)).foregroundColor(Color.zxF04)
}.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
@@ -32,7 +32,7 @@ struct AIChatPage: View {
VStack(spacing: 4) {
ForEach(citations.prefix(3)) { c in
HStack(spacing: 4) {
- Image("icon-file").font(.system(size: 9)).foregroundColor(Color.zxF04)
+ Image(systemName: "doc").font(.system(size: 10)).foregroundColor(Color.zxF04)
Text(c.excerptText?.prefix(60).description ?? "").font(.system(size: 10)).foregroundColor(Color.zxF04).lineLimit(1)
}.padding(.horizontal, 8).padding(.vertical, 4)
.background(Color.zxFill004).clipShape(Capsule())
@@ -43,10 +43,10 @@ struct AIChatPage: View {
if m.role == .ai {
HStack(spacing: 16) {
Button { UIPasteboard.general.string = m.content } label: {
- Label("复制", systemImage: "doc.on.doc").font(.system(size: 11)).foregroundColor(Color.zxF04)
+ Label("复制", systemImage: "doc.on.doc").font(.system(size: 12)).foregroundColor(Color.zxF04)
}
Button { Task { vm.send() } } label: {
- Label("重新生成", systemImage: "arrow.clockwise").font(.system(size: 11)).foregroundColor(Color.zxF04)
+ Label("重新生成", systemImage: "arrow.clockwise").font(.system(size: 12)).foregroundColor(Color.zxF04)
}
}.padding(.leading, 36)
}
@@ -56,7 +56,7 @@ struct AIChatPage: View {
HStack(spacing: 8) {
Image("icon-brain").foregroundColor(Color.zxPurple)
.frame(width: 28, height: 28).background(Color(hex: "#7C6EFA", opacity: 0.15)).clipShape(Circle())
- ZXDotLoader(color: Color.zxPurple).padding(.leading, 4); Spacer()
+ ProgressView().tint(Color.zxPurple).padding(.leading, 4); Spacer()
}.padding(.horizontal, 20)
}
}.padding(.top, 8).padding(.bottom, 100)
@@ -68,7 +68,7 @@ struct AIChatPage: View {
}
}
}
- .navigationBarTitleDisplayMode(.inline).hideTabBarWithAnimation()
+ .navigationBarTitleDisplayMode(.inline).toolbar(.hidden, for: .tabBar)
.toolbarBackground(.hidden, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
@@ -94,7 +94,7 @@ struct AIChatPage: View {
HStack {
Text(s.title ?? "对话").font(.system(size: 14)).foregroundColor(Color.zxF0)
Spacer()
- Text(s.updatedAt?.prefix(10).description ?? "").font(.system(size: 11)).foregroundColor(Color.zxF04)
+ Text(s.updatedAt?.prefix(10).description ?? "").font(.system(size: 12)).foregroundColor(Color.zxF04)
}.padding(.horizontal, 20).padding(.vertical, 14)
}.foregroundColor(.primary)
}
diff --git a/AIStudyApp/AIStudyApp/Features/AI/AIFeedbackPageView.swift b/AIStudyApp/AIStudyApp/Features/AI/AIFeedbackPageView.swift
index 6f721ef..345e67c 100644
--- a/AIStudyApp/AIStudyApp/Features/AI/AIFeedbackPageView.swift
+++ b/AIStudyApp/AIStudyApp/Features/AI/AIFeedbackPageView.swift
@@ -27,7 +27,7 @@ struct AIFeedbackPageView: View {
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)
+ Text("/ 100").font(.system(size: 10)).foregroundColor(Color.zxF04)
}
}
VStack(alignment: .leading, spacing: 2) {
@@ -42,12 +42,12 @@ struct AIFeedbackPageView: View {
.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: 14, weight: .semibold)).foregroundColor(Color.zxF04)
Text("过拟合就像一个学生只会「死记硬背」考题,而不是真正理解知识…").zxFontScaled(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("icon-check").foregroundColor(Color.zxGreen)
+ Image(systemName: "checkmark.circle").foregroundColor(Color.zxGreen)
Text("答对的部分").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0)
}
ForEach(["正确识别出过拟合是\"记住训练数据\"而非\"学习规律\"", "使用了死记硬背类比,方向正确且贴切"], id: \.self) { s in
@@ -62,7 +62,7 @@ struct AIFeedbackPageView: View {
}
}
NavigationLink(value: Route.studyHome) {
- Label("加入待巩固,安排间隔复习", systemImage: "bolt.fill")
+ Label("加入待巩固,安排间隔复习", systemImage: "bolt")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.white)
.frame(maxWidth: .infinity).frame(height: 52)
@@ -73,7 +73,7 @@ struct AIFeedbackPageView: View {
HStack(spacing: 12) {
NavigationLink(value: Route.aiChat) {
HStack(spacing: 4) {
- Text("深入提问").font(.system(size: 13))
+ Text("深入提问").font(.system(size: 14))
Image("icon-chevron-right").resizable().scaledToFit().frame(width: 14, height: 14)
}
.foregroundColor(Color.zxF05)
@@ -84,7 +84,7 @@ struct AIFeedbackPageView: View {
}
NavigationLink(value: Route.dailyThinking) {
HStack(spacing: 4) {
- Text("再来一题").font(.system(size: 13))
+ Text("再来一题").font(.system(size: 14))
Image("icon-chevron-right").resizable().scaledToFit().frame(width: 14, height: 14)
}
.foregroundColor(Color.zxF05)
@@ -101,7 +101,7 @@ struct AIFeedbackPageView: View {
.transition(.opacity.combined(with: .scale(scale: 0.95)))
}
}
- .navigationBarTitleDisplayMode(.inline).hideTabBarWithAnimation()
+ .navigationBarTitleDisplayMode(.inline).toolbar(.hidden, for: .tabBar)
.toolbarBackground(.hidden, for: .navigationBar)
}
}
diff --git a/AIStudyApp/AIStudyApp/Features/AI/AIHomeView.swift b/AIStudyApp/AIStudyApp/Features/AI/AIHomeView.swift
index 2b653d3..792bd80 100644
--- a/AIStudyApp/AIStudyApp/Features/AI/AIHomeView.swift
+++ b/AIStudyApp/AIStudyApp/Features/AI/AIHomeView.swift
@@ -34,7 +34,7 @@ struct ZXRivePlaceholder: View {
.symbolEffect(.pulse, options: .repeating.speed(0.5))
Text(label)
- .font(.system(size: 13, weight: .medium))
+ .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.zxInkSecondary)
}
}
@@ -166,7 +166,7 @@ struct AIHomeView: View {
// Tag
HStack(spacing: 6) {
Image("icon-sparkles")
- .font(.system(size: 11))
+ .font(.system(size: 12))
Text("AI 驱动学习")
.font(.system(size: ZXFont.caption2.size, weight: .semibold))
}
@@ -234,7 +234,7 @@ struct AIHomeView: View {
NavigationLink(value: Route.aiChat) {
quickActionItem(
- icon: "mic.fill",
+ icon: "mic",
label: "费曼\n解释练习"
)
}
@@ -261,7 +261,7 @@ struct AIHomeView: View {
.clipShape(RoundedRectangle(cornerRadius: ZXRadius.md))
Text(label)
- .font(.system(size: 11, weight: .medium))
+ .font(.system(size: 12, weight: .medium))
.foregroundColor(Color.zxInkSecondary)
.multilineTextAlignment(.center)
.lineSpacing(3)
@@ -291,7 +291,7 @@ struct AIHomeView: View {
// Header
HStack {
Image("icon-sparkles")
- .font(.system(size: 13))
+ .font(.system(size: 14))
.foregroundColor(Color.zxPrimary)
.frame(width: 30, height: 30)
.background(Color.zxPrimarySoft)
@@ -304,7 +304,7 @@ struct AIHomeView: View {
Spacer()
Text("待回答")
- .font(.system(size: 11, weight: .semibold))
+ .font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.zxAmberDeep)
.padding(.horizontal, 8)
.padding(.vertical, 3)
@@ -357,7 +357,7 @@ struct AIHomeView: View {
.tracking(0.5)
Spacer()
Text("查看全部")
- .font(.system(size: 13, weight: .medium))
+ .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.zxPrimary)
}
@@ -376,7 +376,7 @@ struct AIHomeView: View {
} label: {
HStack(spacing: 10) {
Image(systemName: icon)
- .font(.system(size: 13))
+ .font(.system(size: 14))
.foregroundColor(Color.zxInkTertiary)
.frame(width: 32, height: 32)
.background(Color.zxSurface)
@@ -408,7 +408,7 @@ struct AIHomeView: View {
private var inputBar: some View {
HStack(spacing: 10) {
Image("icon-sparkles")
- .font(.system(size: 15))
+ .font(.system(size: 16))
.foregroundColor(Color.zxPrimary)
TextField("问 AI 任何学习问题…", text: $text)
@@ -420,7 +420,7 @@ struct AIHomeView: View {
// 语音按钮 — 占位
Button {} label: {
Image("icon-mic")
- .font(.system(size: 17))
+ .font(.system(size: 18))
.foregroundColor(Color.zxInkTertiary)
}
diff --git a/AIStudyApp/AIStudyApp/Features/AI/ActiveRecallView.swift b/AIStudyApp/AIStudyApp/Features/AI/ActiveRecallView.swift
index 8f54724..b519749 100644
--- a/AIStudyApp/AIStudyApp/Features/AI/ActiveRecallView.swift
+++ b/AIStudyApp/AIStudyApp/Features/AI/ActiveRecallView.swift
@@ -39,7 +39,7 @@ struct ActiveRecallView: View {
.scrollIndicators(.hidden)
}
}
- .navigationBarTitleDisplayMode(.inline).hideTabBarWithAnimation()
+ .navigationBarTitleDisplayMode(.inline).toolbar(.hidden, for: .tabBar)
.toolbarBackground(.hidden, for: .navigationBar)
.task { await viewModel.loadQuestions() }
.overlay {
@@ -97,7 +97,7 @@ struct ActiveRecallView: View {
Text(current.source).font(.system(size: 10)).foregroundColor(Color.zxF03)
}
Text(current.question)
- .font(.system(size: 15, weight: .semibold))
+ .font(.system(size: 16, weight: .semibold))
.foregroundColor(Color.zxF0)
.lineSpacing(5)
}
@@ -118,7 +118,7 @@ struct ActiveRecallView: View {
voiceInputArea
} else {
TextEditor(text: $currentAnswer)
- .font(.system(size: 13))
+ .font(.system(size: 14))
.foregroundColor(Color.zxF0)
.tint(Color.zxPurple)
.frame(minHeight: 150)
@@ -174,7 +174,7 @@ struct ActiveRecallView: View {
private var submittedView: some View {
VStack(spacing: 16) {
HStack(spacing: 10) {
- Image("icon-check").font(.system(size: 22)).foregroundColor(Color.zxGreen)
+ Image(systemName: "checkmark.circle").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)
diff --git a/AIStudyApp/AIStudyApp/Features/AI/DailyThinkingPage.swift b/AIStudyApp/AIStudyApp/Features/AI/DailyThinkingPage.swift
index 1e15ea9..d5a1b0c 100644
--- a/AIStudyApp/AIStudyApp/Features/AI/DailyThinkingPage.swift
+++ b/AIStudyApp/AIStudyApp/Features/AI/DailyThinkingPage.swift
@@ -12,6 +12,6 @@ struct DailyThinkingPage: View {
VStack(alignment:.leading,spacing:8){Text("你的回答").font(.system(size:13,weight:.semibold)).foregroundColor(Color.zxF04);TextEditor(text:$answer).zxFontScaled(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(value: Route.aiFeedback){ Text("提交回答,获取 AI 反馈").font(.system(size:14,weight:.bold)).foregroundColor(.white).frame(maxWidth:.infinity).frame(height:52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius:16)).shadow(color:Color(hex:"#7C6EFA",opacity:0.3),radius:24) }.zxPressable() }
}.padding(.horizontal,20).padding(.top, 8).padding(.bottom,120) }.scrollIndicators(.hidden)
- }.navigationBarTitleDisplayMode(.inline).hideTabBarWithAnimation().toolbarBackground(.hidden,for:.navigationBar)
+ }.navigationBarTitleDisplayMode(.inline).toolbar(.hidden, for: .tabBar).toolbarBackground(.hidden,for:.navigationBar)
}
}
diff --git a/AIStudyApp/AIStudyApp/Features/AI/RecallTestPage.swift b/AIStudyApp/AIStudyApp/Features/AI/RecallTestPage.swift
index a144f1d..f07e52e 100644
--- a/AIStudyApp/AIStudyApp/Features/AI/RecallTestPage.swift
+++ b/AIStudyApp/AIStudyApp/Features/AI/RecallTestPage.swift
@@ -32,7 +32,7 @@ struct RecallTestPage: View {
}
.scrollIndicators(.hidden)
}
- .navigationBarTitleDisplayMode(.inline).hideTabBarWithAnimation()
+ .navigationBarTitleDisplayMode(.inline).toolbar(.hidden, for: .tabBar)
.toolbarBackground(.hidden, for: .navigationBar)
}
}
diff --git a/AIStudyApp/AIStudyApp/Features/AI/WeakPointsPage.swift b/AIStudyApp/AIStudyApp/Features/AI/WeakPointsPage.swift
index cbcf715..8d147ed 100644
--- a/AIStudyApp/AIStudyApp/Features/AI/WeakPointsPage.swift
+++ b/AIStudyApp/AIStudyApp/Features/AI/WeakPointsPage.swift
@@ -16,7 +16,7 @@ struct WeakPointsPage: View {
}
.scrollIndicators(.hidden)
}
- .navigationBarTitleDisplayMode(.inline).hideTabBarWithAnimation()
+ .navigationBarTitleDisplayMode(.inline).toolbar(.hidden, for: .tabBar)
.toolbarBackground(.hidden, for: .navigationBar)
}
}
diff --git a/AIStudyApp/AIStudyApp/Features/Analysis/ActivityViewModel.swift b/AIStudyApp/AIStudyApp/Features/Analysis/ActivityViewModel.swift
index d91004e..446d3a8 100644
--- a/AIStudyApp/AIStudyApp/Features/Analysis/ActivityViewModel.swift
+++ b/AIStudyApp/AIStudyApp/Features/Analysis/ActivityViewModel.swift
@@ -15,33 +15,47 @@ class ActivityViewModel: ObservableObject {
func loadAll() async {
isLoading = true
errorMessage = nil
- do {
- async let s = ActivityService.shared.summary()
- async let f = FocusItemService.shared.list()
- async let h = ActivityService.shared.heatmap()
- async let st = ActivityService.shared.streak()
- async let t = ActivityService.shared.trend()
- async let r = ActivityService.shared.recommendations()
- let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult) = try await (s, f, h, st, t, r)
- summary = summaryResult; focusItems = focusResult; heatmap = heatmapResult
- streak = streakResult; trends = trendResult; recommendations = recResult
- } catch {
- if summary == nil { errorMessage = "加载分析数据失败" }
+
+ async let s = try? ActivityService.shared.summary()
+ async let f = try? FocusItemService.shared.list()
+ async let h = try? ActivityService.shared.heatmap()
+ async let st = try? ActivityService.shared.streak()
+ async let t = try? ActivityService.shared.trend()
+ async let r = try? ActivityService.shared.recommendations()
+
+ let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult) = await (s, f, h, st, t, r)
+
+ summary = summaryResult
+ focusItems = focusResult ?? []
+ heatmap = heatmapResult ?? [:]
+ streak = streakResult
+ trends = trendResult ?? []
+ recommendations = recResult ?? []
+
+ if summary == nil {
+ errorMessage = "加载分析数据失败,请下拉刷新重试"
}
+
isLoading = false
}
func refresh() async {
- do {
- async let s = ActivityService.shared.summary()
- async let f = FocusItemService.shared.list()
- async let h = ActivityService.shared.heatmap()
- async let st = ActivityService.shared.streak()
- async let t = ActivityService.shared.trend()
- async let r = ActivityService.shared.recommendations()
- let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult) = try await (s, f, h, st, t, r)
- summary = summaryResult; focusItems = focusResult; heatmap = heatmapResult
- streak = streakResult; trends = trendResult; recommendations = recResult
- } catch {}
+ errorMessage = nil
+
+ async let s = try? ActivityService.shared.summary()
+ async let f = try? FocusItemService.shared.list()
+ async let h = try? ActivityService.shared.heatmap()
+ async let st = try? ActivityService.shared.streak()
+ async let t = try? ActivityService.shared.trend()
+ async let r = try? ActivityService.shared.recommendations()
+
+ let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult) = await (s, f, h, st, t, r)
+
+ summary = summaryResult
+ focusItems = focusResult ?? []
+ heatmap = heatmapResult ?? [:]
+ streak = streakResult
+ trends = trendResult ?? []
+ recommendations = recResult ?? []
}
}
diff --git a/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift b/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift
index 6f28b40..30f6e76 100644
--- a/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift
+++ b/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift
@@ -15,14 +15,26 @@ struct AnalysisHomeView: View {
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 12)
ScrollView {
VStack(spacing: 16) {
- if viewModel.isLoading && viewModel.summary == nil {
- VStack(spacing: 12) { ZXLoadingView(size: 36, lineWidth: 3); Text("加载中…").font(.system(size: 13)).foregroundColor(Color.zxF04) }
- .frame(maxWidth: .infinity).padding(.top, 80)
+ if viewModel.isLoading {
+ VStack(spacing: 12) {
+ ProgressView().tint(Color.zxPrimary)
+ Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04)
+ }
+ .frame(maxWidth: .infinity).padding(.top, 80)
+ } else if let error = viewModel.errorMessage {
+ VStack(spacing: 16) {
+ Image(systemName: "exclamationmark.triangle").font(.system(size: 40)).foregroundColor(Color.zxF03)
+ Text(error).font(.system(size: 14)).foregroundColor(Color.zxF04).multilineTextAlignment(.center)
+ Button("重试") { Task { await viewModel.loadAll() } }
+ .font(.system(size: 14, weight: .semibold)).foregroundColor(.white).frame(height: 44).padding(.horizontal, 32)
+ .background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: ZXRadius.md))
+ }
+ .frame(maxWidth: .infinity).padding(.top, 80)
}
HStack(spacing: 12) {
- ZXStatBadge(icon: "trophy.fill", label: "综合掌握", value: "\(viewModel.summary?.dailyAverage ?? 0)%", trend: "", color: Color.zxPurple)
- ZXStatBadge(icon: "bolt.fill", label: "总分钟", value: "\(viewModel.summary?.totalMinutes ?? 0)", trend: "", color: Color.zxOrange)
- ZXStatBadge(icon: "exclamationmark.triangle.fill", label: "复习卡片", value: "\(viewModel.summary?.totalCardsReviewed ?? 0)", trend: "", color: Color.zxYellow)
+ ZXStatBadge(icon: "trophy", label: "综合掌握", value: "\(viewModel.summary?.dailyAverage ?? 0)%", trend: "", color: Color.zxPurple)
+ ZXStatBadge(icon: "bolt", label: "总分钟", value: "\(viewModel.summary?.totalMinutes ?? 0)", trend: "", color: Color.zxOrange)
+ ZXStatBadge(icon: "exclamationmark.triangle", label: "复习卡片", value: "\(viewModel.summary?.totalCardsReviewed ?? 0)", trend: "", color: Color.zxYellow)
ZXStatBadge(icon: "chart.line.uptrend.xyaxis", label: "活跃天", value: "\(viewModel.summary?.activeDays ?? 0)", trend: "", color: Color.zxGreen)
}
VStack(alignment: .leading, spacing: 16) {
@@ -32,13 +44,13 @@ struct AnalysisHomeView: View {
VStack(alignment: .leading, spacing: 14) {
Text("本周学习活跃").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0)
ZXWeekBarChart()
- 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: 12)).foregroundColor(Color.zxF03); Spacer(); Text("日均 30 分钟").font(.system(size: 12)).foregroundColor(Color.zxF03) }
}.padding(16).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20))
if let streak = viewModel.streak {
HStack(spacing: 12) {
- ZXStatBadge(icon: "flame.fill", label: "连续学习", value: "\(streak.currentStreak ?? 0) 天", trend: "", color: Color.zxOrange)
- ZXStatBadge(icon: "trophy.fill", label: "最长连续", value: "\(streak.longestStreak ?? 0) 天", trend: "", color: Color.zxAmber)
+ ZXStatBadge(icon: "flame", label: "连续学习", value: "\(streak.currentStreak ?? 0) 天", trend: "", color: Color.zxOrange)
+ ZXStatBadge(icon: "trophy", label: "最长连续", value: "\(streak.longestStreak ?? 0) 天", trend: "", color: Color.zxAmber)
ZXStatBadge(icon: "calendar", label: "最后活跃", value: streak.lastActiveDate.flatMap { String($0.prefix(10)) } ?? "-", trend: "", color: Color.zxPrimary)
}
}
@@ -47,10 +59,10 @@ struct AnalysisHomeView: View {
Text("学习推荐").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0)
ForEach(viewModel.recommendations.prefix(3)) { rec in
HStack(spacing: 10) {
- Image(systemName: rec.type == "review" ? "arrow.triangle.2.circlepath" : "lightbulb.fill")
+ Image(systemName: rec.type == "review" ? "arrow.triangle.2.circlepath" : "lightbulb")
.font(.system(size: 14)).foregroundColor(Color.zxAccent).frame(width: 32, height: 32)
- VStack(alignment: .leading, spacing: 2) { Text(rec.title ?? "").font(.system(size: 13, weight: .semibold)).foregroundColor(Color.zxF0); if let desc = rec.description { Text(desc).font(.system(size: 11)).foregroundColor(Color.zxF04).lineLimit(1) } }
+ VStack(alignment: .leading, spacing: 2) { Text(rec.title ?? "").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0); if let desc = rec.description { Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF04).lineLimit(1) } }
Spacer()
if let p = rec.priority { Text(p).font(.system(size: 10, weight: .bold)).foregroundColor(p == "high" ? Color.zxCoral : Color.zxAmber).padding(.horizontal, 6).padding(.vertical, 2).background((p == "high" ? Color.zxCoral : Color.zxAmber).opacity(0.1)).clipShape(Capsule()) }
}.padding(10).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 12))
@@ -61,18 +73,18 @@ struct AnalysisHomeView: View {
if let summary = viewModel.summary {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) { Image("icon-brain").font(.system(size: 14)).foregroundColor(Color.zxPurple); Text("AI 综合分析").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0) }
- Text(aiAnalysisText).font(.system(size: 13)).foregroundColor(Color.zxF05).lineSpacing(4)
+ Text(aiAnalysisText).font(.system(size: 14)).foregroundColor(Color.zxF05).lineSpacing(4)
}.padding(16).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20))
}
VStack(alignment: .leading, spacing: 12) {
- HStack { HStack(spacing: 8) { Image("icon-warning").font(.system(size: 14)).foregroundColor(Color.zxYellow); Text("薄弱知识点").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0) }; Spacer(); NavigationLink(value: Route.weakPoints) { Text("全部 \(viewModel.focusItems.count) 个").font(.system(size: 12)).foregroundColor(Color.zxPurple) } }
+ HStack { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle").font(.system(size: 14)).foregroundColor(Color.zxYellow); Text("薄弱知识点").font(.system(size: 16, weight: .bold)).foregroundColor(Color.zxF0) }; Spacer(); NavigationLink(value: Route.weakPoints) { Text("全部 \(viewModel.focusItems.count) 个").font(.system(size: 12)).foregroundColor(Color.zxPurple) } }
ForEach(viewModel.focusItems.prefix(5)) { item in ZXWeakRow(score: item.masteryScore ?? 0, topic: item.title, lib: item.knowledgeBaseId ?? "", priority: item.priority ?? "normal") }
- if viewModel.focusItems.isEmpty && !viewModel.isLoading { Text("暂无薄弱知识点").font(.system(size: 13)).foregroundColor(Color.zxF03) }
+ if viewModel.focusItems.isEmpty && !viewModel.isLoading { Text("暂无薄弱知识点").font(.system(size: 14)).foregroundColor(Color.zxF03) }
}
}.padding(.horizontal, 20).padding(.bottom, 120)
}
.scrollIndicators(.hidden)
- .zxPullToRefresh { await viewModel.refresh() }
+ .refreshable { await viewModel.refresh() }
}
}
.task { await viewModel.loadAll() }
@@ -97,7 +109,7 @@ struct ZXStatBadge: View { let icon: String; let label: String; let value: Strin
VStack(spacing: 3) {
Image(systemName: icon).font(.system(size: 14)).foregroundColor(color)
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: 10)).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))
}
}
@@ -138,7 +150,7 @@ struct ZXChartView: View {
.animation(reduceMotion ? nil : .easeOut(duration: 1.0), value: showChart)
}
}.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: 10)).foregroundColor(Color(hex: "#F0F0FF", opacity: 0.35)).frame(maxWidth: .infinity) } }
}
.onAppear { showChart = true }
.animation(reduceMotion ? nil : .default, value: showChart)
diff --git a/AIStudyApp/AIStudyApp/Features/Library/LibraryHomeView.swift b/AIStudyApp/AIStudyApp/Features/Library/LibraryHomeView.swift
index d5b88eb..7344233 100644
--- a/AIStudyApp/AIStudyApp/Features/Library/LibraryHomeView.swift
+++ b/AIStudyApp/AIStudyApp/Features/Library/LibraryHomeView.swift
@@ -35,7 +35,7 @@ struct LibraryHomeView: View {
viewModel.currentFilter = f
Task { await viewModel.loadKnowledgeBases() }
} label: {
- Text(f.rawValue).font(.system(size: 13, weight: .medium))
+ Text(f.rawValue).font(.system(size: 14, weight: .medium))
.foregroundColor(viewModel.currentFilter == f ? Color.zxOnPrimary : Color.zxF05)
.padding(.horizontal, 14).padding(.vertical, 7)
.background(viewModel.currentFilter == f ? AnyView(ZXGradient.brand) : AnyView(Color.zxFill004))
@@ -48,11 +48,11 @@ struct LibraryHomeView: View {
ScrollView { VStack(spacing: 12) {
if viewModel.isLoading && viewModel.knowledgeBases.isEmpty {
- VStack(spacing: 12) { ZXLoadingView(size: 36, lineWidth: 3); Text("加载中…").font(.system(size: 13)).foregroundColor(Color.zxF04) }.frame(maxWidth: .infinity).padding(.top, 80)
+ VStack(spacing: 12) { ProgressView(); Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04) }.frame(maxWidth: .infinity).padding(.top, 80)
}
ForEach(viewModel.knowledgeBases) { kb in
NavigationLink(value: Route.libraryDetail(knowledgeBaseId: kb.id)) {
- ZLibraryCard(coverUrl: kb.coverUrl, name: kb.title, desc: kb.description ?? "", items: kb.itemCount ?? 0, last: lastStudiedText(kb.lastStudiedAt), isPinned: kb.isPinned ?? false, visibility: kb.visibility ?? "private", ownerType: kb.ownerType ?? "user")
+ ZLibraryCard(coverUrl: kb.coverUrl, name: kb.title, desc: kb.description ?? "", updatedAt: lastStudiedText(kb.updatedAt), isPinned: kb.isPinned ?? false, visibility: kb.visibility ?? "private", ownerType: kb.ownerType ?? "user")
}
}
if viewModel.knowledgeBases.isEmpty && !viewModel.isLoading {
@@ -63,7 +63,7 @@ struct LibraryHomeView: View {
}
}.padding(.horizontal, 20).padding(.bottom, 120) }
.scrollIndicators(.hidden)
- .zxPullToRefresh { await viewModel.refresh() }
+ .refreshable { await viewModel.refresh() }
}
}
.task { await viewModel.loadKnowledgeBases() }
@@ -83,37 +83,99 @@ struct LibraryHomeView: View {
return iso.prefix(10).description
}
}
-struct ZLibraryCard: View { let coverUrl: String?; let name: String; let desc: String; let items: Int; let last: String; let isPinned: Bool; let visibility: String; let ownerType: String
- var body: some View { VStack(spacing: 0) {
- HStack(spacing: 12) {
+
+struct ZLibraryCard: View { let coverUrl: String?; let name: String; let desc: String; let updatedAt: String; let isPinned: Bool; let visibility: String; let ownerType: String
+
+ private var displayDesc: String {
+ let d = desc.trimmingCharacters(in: .whitespacesAndNewlines)
+ if d.isEmpty { return "暂无简介" }
+ return d
+ }
+
+ private var sourceLabel: String {
+ ownerType == "official" ? "官方" : "个人"
+ }
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 14) {
+ // Cover image
ZStack {
- RoundedRectangle(cornerRadius: 13).fill(Color.zxPurpleBG(0.12)).frame(width: 56, height: 56)
- if let url = coverUrl, let imageUrl = URL(string: url) {
+ RoundedRectangle(cornerRadius: 14)
+ .fill(Color.zxPurpleBG(0.12))
+ .frame(width: 90, height: 90)
+ if let url = coverUrl, let imageUrl = resolvedURL(url) {
AsyncImage(url: imageUrl) { phase in
switch phase {
- case .success(let img): img.resizable().scaledToFill().frame(width: 56, height: 56).clipShape(RoundedRectangle(cornerRadius: 13))
- default: Image("icon-books").font(.system(size: 22)).foregroundColor(Color.zxPurple.opacity(0.5))
+ case .success(let img):
+ img.resizable().scaledToFill()
+ .frame(width: 90, height: 90)
+ .clipShape(RoundedRectangle(cornerRadius: 14))
+ default:
+ fallbackIcon
}
}
} else {
- Image("icon-books").font(.system(size: 22)).foregroundColor(Color.zxPurple.opacity(0.5))
+ fallbackIcon
}
}
+
+ // Content
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
- Text(name).font(.system(size: 16, weight: .bold)).foregroundColor(Color.zxF0)
- if isPinned { Image("icon-pin").font(.system(size: 10)).foregroundColor(Color.zxOrange) }
- if visibility == "public" { Text("公开").font(.system(size: 9, weight: .semibold)).foregroundColor(Color.zxGreen).padding(.horizontal, 5).padding(.vertical, 1).background(Color.zxGreen.opacity(0.12)).clipShape(Capsule()) }
+ Text(name)
+ .font(.system(size: 18, weight: .semibold))
+ .foregroundColor(Color.zxF0)
+ .lineLimit(1)
+ Spacer()
+ if isPinned {
+ Image("icon-pin")
+ .font(.system(size: 12))
+ .foregroundColor(Color.zxOrange)
+ }
+ if visibility == "public" {
+ Text("公开")
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundColor(Color.zxGreen)
+ .padding(.horizontal, 6).padding(.vertical, 2)
+ .background(Color.zxGreen.opacity(0.12))
+ .clipShape(Capsule())
+ }
+ }
+
+ Text(displayDesc)
+ .font(.system(size: 14))
+ .foregroundColor(Color.zxF04)
+ .lineLimit(2, reservesSpace: true)
+
+ Spacer().frame(height: 4)
+
+ HStack {
+ Text(updatedAt)
+ .font(.system(size: 14))
+ .foregroundColor(Color.zxF03)
+ Spacer()
+ Text(sourceLabel)
+ .font(.system(size: 14))
+ .foregroundColor(Color.zxF03)
}
- if !desc.isEmpty { Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF04).lineLimit(1) }
}
- Spacer()
- }.padding(16)
- HStack {
- HStack(spacing: 4) { Image("icon-clock").font(.system(size: 10)); Text("\(items) 项 · \(last)").font(.system(size: 11)) }.foregroundColor(Color.zxF03)
- Spacer()
- }.padding(.horizontal, 16).padding(.bottom, 12) }
- .background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 20)).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)) }
+ }
+ .padding(16)
+ .background(Color.zxSurfaceElevated)
+ .clipShape(RoundedRectangle(cornerRadius: ZXRadius.xl))
+ .overlay(RoundedRectangle(cornerRadius: ZXRadius.xl).stroke(Color.zxHairline, lineWidth: 0.5))
+ }
+
+ private var fallbackIcon: some View {
+ Image("icon-books")
+ .font(.system(size: 26))
+ .foregroundColor(Color.zxPurple.opacity(0.4))
+ }
+
+ private func resolvedURL(_ urlString: String) -> URL? {
+ if urlString.hasPrefix("http") { return URL(string: urlString) }
+ return URL(string: "https://longde.cloud\(urlString)")
+ }
}
struct LibrarySearchView: View {
@@ -128,11 +190,11 @@ struct LibrarySearchView: View {
if query.isEmpty {
VStack(spacing: 12) {
Image("icon-search").font(.system(size: 36)).foregroundColor(Color.zxF03)
- Text("搜索知识点、知识库或标签").font(.system(size: 13)).foregroundColor(Color.zxF03)
+ Text("搜索知识点、知识库或标签").font(.system(size: 14)).foregroundColor(Color.zxF03)
}.padding(.top, 80)
}
}.padding(.horizontal, 20) }.scrollIndicators(.hidden)
}
- }.navigationBarTitleDisplayMode(.inline).hideTabBarWithAnimation().toolbarBackground(.hidden, for: .navigationBar)
+ }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)
}
}
diff --git a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift
index 5944549..2ffca94 100644
--- a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift
+++ b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift
@@ -24,8 +24,8 @@ struct CreateLibraryPage: View {
Image(uiImage: img).resizable().scaledToFill().frame(width: 120, height: 120).clipShape(RoundedRectangle(cornerRadius: 14))
} else {
VStack(spacing: 6) {
- Image(systemName: "icon-camera").font(.system(size: 22)).foregroundColor(Color.zxF04)
- Text("上传").font(.system(size: 11)).foregroundColor(Color.zxF04)
+ Image(systemName: "camera").font(.system(size: 22)).foregroundColor(Color.zxF04)
+ Text("上传").font(.system(size: 12)).foregroundColor(Color.zxF04)
}
}
if isUploadingCover { RoundedRectangle(cornerRadius: 14).fill(Color.black.opacity(0.4)).frame(width: 120, height: 120); ProgressView().tint(.white) }
@@ -37,7 +37,7 @@ struct CreateLibraryPage: View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 2) { Text("知识库名称").font(.system(size: 12, weight: .semibold)); Text("*").foregroundColor(.red) }.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))
+ TextField("例如:机器学习", text: $name).font(.system(size: 16)).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) {
HStack(spacing: 2) { Text("描述").font(.system(size: 12, weight: .semibold)); Text("*").foregroundColor(.red) }.foregroundColor(Color.zxF035)
@@ -72,7 +72,7 @@ struct CreateLibraryPage: View {
}
.disabled(isCreating || name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || desc.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}.padding(.horizontal, 20).padding(.top, 20) }.scrollIndicators(.hidden) }
- }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).hideTabBarWithAnimation()
+ }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar)
.photosPicker(isPresented: $showCoverPicker, selection: $coverPhotoItem, matching: .images)
.onChange(of: coverPhotoItem) { _, item in
guard let item else { return }
@@ -89,7 +89,7 @@ struct CreateLibraryPage: View {
HStack(spacing: 12) {
Image("icon-camera").font(.system(size: 20)).foregroundColor(Color.zxPrimary).frame(width: 40, height: 40).background(Color.zxPrimarySoft).clipShape(RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading, spacing: 2) {
- Text("从相册选择").font(.system(size: 15, weight: .medium)).foregroundColor(Color.zxF0)
+ Text("从相册选择").font(.system(size: 16, weight: .medium)).foregroundColor(Color.zxF0)
Text("选择一张图片作为封面").font(.system(size: 12)).foregroundColor(Color.zxF04)
}
Spacer()
@@ -141,6 +141,7 @@ struct LibraryDetailPage: View {
@State private var selectedIds: Set = []
@State private var showBatchDeleteConfirm = false
@State private var detailTab = 0
+ @State private var sortOption = 0
@State private var sources: [KnowledgeSource] = []
@State private var isLoadingSources = false
@@ -149,48 +150,99 @@ struct LibraryDetailPage: View {
}
var body: some View {
- ZStack {
+ ZStack(alignment: .top) {
Color.zxBg0.ignoresSafeArea()
+
+ // Top gradient wash — blue/purple fading to transparent
+ LinearGradient(
+ colors: [
+ Color.zxPurple.opacity(0.08),
+ Color.zxPurple.opacity(0.04),
+ Color.clear,
+ ],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ .frame(height: 220)
+ .ignoresSafeArea(edges: .top)
+
VStack(spacing: 0) {
+ // KB info header — fixed, non-scrolling
+ if let kb = viewModel.knowledgeBase {
+ kbInfoHeader(kb)
+ .padding(.horizontal, 20).padding(.top, 8)
+ }
+
Picker("", selection: $detailTab) {
Text("知识点").tag(0)
Text("资料来源").tag(1)
}
.pickerStyle(.segmented)
- .padding(.horizontal, 20).padding(.top, 8)
+ .padding(.horizontal, 20).padding(.top, 12)
ScrollView {
- VStack(spacing: 12) {
+ VStack(spacing: 0) {
if detailTab == 0 {
if viewModel.isLoading && viewModel.items.isEmpty {
VStack(spacing: 12) {
- ZXLoadingView(size: 36, lineWidth: 3)
- Text("加载中…").font(.system(size: 13)).foregroundColor(Color.zxF04)
+ ProgressView()
+ Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04)
}
.frame(maxWidth: .infinity).padding(.top, 80)
}
ForEach(viewModel.items) { item in
+ let icon = fileTypeIcon(for: item)
+ let type = fileTypeText(for: item)
+ let date = formatShortDate(item.updatedAt)
+ let progress = progressFor(item)
if isSelectMode {
Button {
if selectedIds.contains(item.id) { selectedIds.remove(item.id) }
else { selectedIds.insert(item.id) }
} label: {
HStack(spacing: 10) {
- Image(systemName: selectedIds.contains(item.id) ? "checkmark.circle.fill" : "circle")
+ Image(systemName: selectedIds.contains(item.id) ? "checkmark.circle" : "circle")
.font(.system(size: 20))
.foregroundColor(selectedIds.contains(item.id) ? Color.zxPrimary : Color.zxF03)
- ZXCardRow(icon: "doc.text", title: item.title, desc: item.summary ?? item.content ?? "", status: item.status ?? "active", c: Color.zxGreen)
+ itemRow(icon: icon, title: item.title, type: type, date: date, progress: progress)
}
}
.foregroundColor(.primary)
} else {
NavigationLink(value: Route.knowledgeDetail(item: item)) {
- ZXCardRow(icon: "doc.text", title: item.title, desc: item.summary ?? item.content ?? "", status: item.status ?? "active", c: Color.zxGreen)
+ itemRow(icon: icon, title: item.title, type: type, date: date, progress: progress)
+ }
+ .contextMenu {
+ Button {
+ isSelectMode = true
+ selectedIds.insert(item.id)
+ } label: {
+ Label("多选", systemImage: "checkmark.circle")
+ }
+ Button {
+ // TODO: rename
+ } label: {
+ Label("重命名", image: "icon-pencil")
+ }
+ Button {
+ // TODO: move to folder
+ } label: {
+ Label("移动到", image: "icon-folder")
+ }
+ Divider()
+ Button(role: .destructive) {
+ Task {
+ await viewModel.batchDeleteItems(ids: [item.id])
+ await viewModel.refresh(knowledgeBaseId: knowledgeBaseId)
+ }
+ } label: {
+ Label("删除", image: "icon-trash")
+ }
}
}
}
if viewModel.items.isEmpty && !viewModel.isLoading {
- Text("暂无知识点").font(.system(size: 13)).foregroundColor(Color.zxF03).padding(.top, 40)
+ Text("暂无知识点").font(.system(size: 14)).foregroundColor(Color.zxF03).padding(.top, 40)
}
if viewModel.hasMore {
ZXLoadMoreFooter { await viewModel.loadMore(knowledgeBaseId: knowledgeBaseId) }
@@ -199,35 +251,35 @@ struct LibraryDetailPage: View {
if isLoadingSources {
VStack(spacing: 12) {
ProgressView().tint(Color.zxPurple)
- Text("加载中…").font(.system(size: 13)).foregroundColor(Color.zxF04)
+ Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04)
}.padding(.top, 80)
} else if sources.isEmpty {
- Text("暂无资料来源").font(.system(size: 13)).foregroundColor(Color.zxF03).padding(.top, 40)
+ Text("暂无资料来源").font(.system(size: 14)).foregroundColor(Color.zxF03).padding(.top, 40)
} else {
ForEach(sources) { src in
HStack(spacing: 12) {
- Image(systemName: src.type == "file" ? "doc.fill" : "link")
+ Image(systemName: src.type == "file" ? "doc" : "link")
.font(.system(size: 18)).foregroundColor(Color.zxPurple)
- .frame(width: 40, height: 40)
-
+ .frame(width: 36, height: 36)
VStack(alignment: .leading, spacing: 2) {
- Text(src.title ?? src.originalFilename ?? "未命名").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0).lineLimit(1)
- HStack(spacing: 6) {
- Text(src.parseStatus ?? "pending").font(.system(size: 10, weight: .semibold))
- .foregroundColor(src.parseStatus == "completed" ? Color.zxGreen : Color.zxAmber)
- .padding(.horizontal, 6).padding(.vertical, 1)
- .background((src.parseStatus == "completed" ? Color.zxGreen : Color.zxAmber).opacity(0.12)).clipShape(Capsule())
- if let len = src.textLength, len > 0 { Text("\(len) 字").font(.system(size: 10)).foregroundColor(Color.zxF04) }
+ Text(src.title ?? src.originalFilename ?? "未命名")
+ .font(.system(size: 15, weight: .medium)).foregroundColor(Color.zxF0).lineLimit(1)
+ if let len = src.textLength, len > 0 {
+ Text("\(len) 字")
+ .font(.system(size: 13)).foregroundColor(Color.zxF04)
}
}
Spacer()
Button {
Task { await deleteSource(src) }
} label: {
- Image("icon-trash").resizable().scaledToFit().frame(width: 18, height: 18).foregroundColor(Color.zxF03)
+ Image("icon-trash").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(Color.zxF03)
}
}
- .padding(12).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 14))
+ .padding(.vertical, 14)
+ .overlay(alignment: .bottom) {
+ Color.zxHairline.frame(height: 0.5)
+ }
}
}
}
@@ -235,10 +287,10 @@ struct LibraryDetailPage: View {
.padding(.horizontal, 20).padding(.bottom, 80)
}
.scrollIndicators(.hidden)
- .zxPullToRefresh { await viewModel.refresh(knowledgeBaseId: knowledgeBaseId) }
+ .refreshable { await viewModel.refresh(knowledgeBaseId: knowledgeBaseId) }
}
}
- .navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).hideTabBarWithAnimation()
+ .navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar)
.onChange(of: detailTab) { _, newTab in
if newTab == 1 && sources.isEmpty { Task { await loadSources() } }
}
@@ -261,25 +313,56 @@ struct LibraryDetailPage: View {
.disabled(selectedIds.isEmpty)
}
} else {
- ToolbarItem(placement: .topBarLeading) {
- Button { showDeleteConfirm = true } label: {
- Image("icon-trash").resizable().scaledToFit().frame(width: 18, height: 18).foregroundColor(Color.zxF03)
- }
- }
ToolbarItem(placement: .topBarTrailing) {
- NavigationLink(value: Route.quizList(knowledgeBaseId: knowledgeBaseId)) {
- Image("icon-question").font(.system(size: 16)).foregroundColor(Color.zxF05)
- }
- }
- ToolbarItem(placement: .topBarTrailing) {
- NavigationLink(value: Route.addKnowledge(knowledgeBaseId: knowledgeBaseId)) {
- Image("icon-plus").resizable().scaledToFit().frame(width: 18, height: 18).foregroundColor(Color.zxF05)
- }
- }
- ToolbarItem(placement: .topBarTrailing) {
- Button { isSelectMode = true } label: {
- Image(systemName: "checkmark.circle").font(.system(size: 16)).foregroundColor(Color.zxF05)
+ HStack(spacing: 4) {
+ Menu {
+ Button { sortOption = 0 } label: {
+ Label("默认排序", systemImage: sortOption == 0 ? "checkmark" : "")
+ }
+ Button { sortOption = 1 } label: {
+ Label("文件大小", systemImage: sortOption == 1 ? "checkmark" : "")
+ }
+ Button { sortOption = 2 } label: {
+ Label("创建日期", systemImage: sortOption == 2 ? "checkmark" : "")
+ }
+ Button { sortOption = 3 } label: {
+ Label("更新日期", systemImage: sortOption == 3 ? "checkmark" : "")
+ }
+ } label: {
+ Image(systemName: "arrow.up.arrow.down")
+ .font(.system(size: 16))
+ .foregroundColor(Color.zxF05)
+ }
+
+ Menu {
+ NavigationLink(value: Route.addKnowledge(knowledgeBaseId: knowledgeBaseId)) {
+ Label("添加知识点", image: "icon-plus")
+ }
+ Button {
+ // TODO: create folder
+ } label: {
+ Label("创建文件夹", image: "icon-folder")
+ }
+ NavigationLink(value: Route.quizList(knowledgeBaseId: knowledgeBaseId)) {
+ Label("答题测验", image: "icon-pencil")
+ }
+ Button {
+ isSelectMode = true
+ } label: {
+ Label("批量选择", systemImage: "checkmark.circle")
+ }
+ Divider()
+ Button {
+ // TODO: knowledge base management page
+ } label: {
+ Label("知识库管理", image: "icon-settings")
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ .font(.system(size: 20))
+ .foregroundColor(Color.zxF05)
}
+ } // HStack
}
}
}
@@ -310,6 +393,198 @@ struct LibraryDetailPage: View {
.task { await viewModel.loadItems(knowledgeBaseId: knowledgeBaseId) }
}
+ private func kbInfoHeader(_ kb: KnowledgeBase) -> some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(alignment: .top, spacing: 16) {
+ // Cover image
+ ZStack {
+ RoundedRectangle(cornerRadius: 16)
+ .fill(Color.zxPurpleBG(0.12))
+ .frame(width: 80, height: 80)
+ if let coverUrl = kb.coverUrl, let url = resolvedCoverURL(coverUrl) {
+ AsyncImage(url: url) { phase in
+ switch phase {
+ case .success(let img):
+ img.resizable().scaledToFill()
+ .frame(width: 80, height: 80)
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+ default:
+ Image("icon-books")
+ .font(.system(size: 34))
+ .foregroundColor(Color.zxPurple.opacity(0.4))
+ }
+ }
+ } else {
+ Image("icon-books")
+ .font(.system(size: 34))
+ .foregroundColor(Color.zxPurple.opacity(0.4))
+ }
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(kb.title)
+ .font(.system(size: 20, weight: .bold))
+ .foregroundColor(Color.zxF0)
+ if let desc = kb.description, !desc.isEmpty {
+ Text(desc)
+ .font(.system(size: 14))
+ .foregroundColor(Color.zxF04)
+ .lineLimit(2)
+ } else {
+ Text("暂无简介")
+ .font(.system(size: 14))
+ .foregroundColor(Color.zxF04)
+ }
+ }
+ }
+
+ // Info rows
+ VStack(alignment: .leading, spacing: 2) {
+ HStack {
+ infoLabel("来源", kb.ownerType == "official" ? "官方" : "个人")
+ .frame(maxWidth: .infinity, alignment: .leading)
+ if let created = kb.createdAt {
+ infoLabel("创建", String(created.prefix(10)))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ HStack {
+ if let count = kb.itemCount {
+ infoLabel("文件", "\(count) 个")
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ if let updated = kb.updatedAt {
+ infoLabel("更新", String(updated.prefix(10)))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ }
+ .font(.system(size: 13))
+ }
+ }
+
+ private func infoLabel(_ label: String, _ value: String) -> some View {
+ HStack(spacing: 4) {
+ Text("\(label):")
+ .foregroundColor(Color.zxF03)
+ Text(value)
+ .foregroundColor(Color.zxF0)
+ }
+ }
+
+ private func resolvedCoverURL(_ urlString: String) -> URL? {
+ if urlString.hasPrefix("http") {
+ return URL(string: urlString)
+ }
+ return URL(string: "https://longde.cloud\(urlString)")
+ }
+
+ private func fileTypeText(for item: KnowledgeItem) -> String {
+ if let t = item.sourceType?.trimmingCharacters(in: .whitespaces), !t.isEmpty {
+ return t.uppercased()
+ }
+ let title = item.title.lowercased()
+ if title.hasSuffix(".pdf") { return "PDF" }
+ if title.hasSuffix(".md") { return "MD" }
+ if title.hasSuffix(".html") { return "HTML" }
+ if title.hasSuffix(".txt") { return "TXT" }
+ if title.hasSuffix(".png") || title.hasSuffix(".jpg") || title.hasSuffix(".jpeg") { return "图片" }
+ return "文件"
+ }
+
+ private func fileTypeIcon(for item: KnowledgeItem) -> String {
+ let t = item.sourceType?.lowercased() ?? ""
+ let title = item.title.lowercased()
+ if t.contains("pdf") || title.hasSuffix(".pdf") { return "doc.richtext" }
+ if t.contains("markdown") || title.hasSuffix(".md") { return "doc.plaintext" }
+ if t.contains("html") || t.contains("code") || title.hasSuffix(".html") { return "doc.text" }
+ if t.contains("image") || title.hasSuffix(".png") || title.hasSuffix(".jpg") { return "photo" }
+ return "doc"
+ }
+
+ private func itemRow(icon: String, title: String, type: String, date: String, progress: CGFloat) -> some View {
+ HStack(spacing: 12) {
+ Image(systemName: icon)
+ .font(.system(size: 18))
+ .foregroundColor(Color.zxPurple)
+ .frame(width: 36, height: 36)
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title)
+ .font(.system(size: 15, weight: .medium))
+ .foregroundColor(Color.zxF0)
+ .lineLimit(1)
+ HStack(spacing: 16) {
+ HStack(spacing: 3) {
+ Text("类型:")
+ .foregroundColor(Color.zxF03)
+ Text(type)
+ .foregroundColor(Color.zxF04)
+ }
+ if !date.isEmpty {
+ HStack(spacing: 3) {
+ Text("创建:")
+ .foregroundColor(Color.zxF03)
+ Text(date)
+ .foregroundColor(Color.zxF04)
+ }
+ }
+ HStack(spacing: 3) {
+ Text("学习:")
+ .foregroundColor(Color.zxF03)
+ Text(formatDuration(progress))
+ .foregroundColor(Color.zxF04)
+ }
+ }
+ .font(.system(size: 12))
+ }
+ Spacer()
+ Image("icon-chevron-right")
+ .resizable().scaledToFit().frame(width: 14, height: 14)
+ .foregroundColor(Color.zxF03)
+ }
+ .padding(.vertical, 14)
+ .overlay(alignment: .bottom) {
+ Color.zxHairline.frame(height: 0.5)
+ }
+ }
+
+ private func formatShortDate(_ iso: String?) -> String {
+ guard let iso else { return "" }
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ let date = formatter.date(from: iso) ?? {
+ formatter.formatOptions = [.withInternetDateTime]
+ return formatter.date(from: iso)
+ }()
+ guard let date else { return "" }
+ let cal = Calendar.current
+ if cal.isDate(date, equalTo: Date(), toGranularity: .year) {
+ let df = DateFormatter()
+ df.dateFormat = "MM/dd"
+ return df.string(from: date)
+ }
+ let df = DateFormatter()
+ df.dateFormat = "yyyy/MM/dd"
+ return df.string(from: date)
+ }
+
+ private func formatDuration(_ progress: CGFloat) -> String {
+ let totalSeconds = Int(progress * 1800) // up to 30 min
+ let h = totalSeconds / 3600
+ let m = (totalSeconds % 3600) / 60
+ let s = totalSeconds % 60
+ if h > 0 {
+ return String(format: "%d:%02d:%02d", h, m, s)
+ }
+ return String(format: "%02d:%02d", m, s)
+ }
+
+ private func progressFor(_ item: KnowledgeItem) -> CGFloat {
+ if item.status == "completed" { return 1.0 }
+ if item.status == "active" { return 0.5 }
+ return 0
+ }
+
private func loadSources() async {
isLoadingSources = true
do { sources = try await KnowledgeSourceService.shared.list(kbId: knowledgeBaseId) } catch {}
@@ -325,7 +600,7 @@ struct LibraryDetailPage: View {
}
}
struct ZXCardRow: View { let icon: String; let title: String; let desc: String; let status: String; let c: Color
- var body: some View { HStack(spacing: 12) { Image(systemName: icon).font(.system(size: 18)).foregroundColor(c).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) { Image(systemName: icon).font(.system(size: 18)).foregroundColor(c).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: 12)).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()) }
.padding(14).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder006, lineWidth: 1)) }
}
@@ -363,7 +638,7 @@ struct AddKnowledgePage: View {
case .manual:
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)
+ TextField("输入知识点标题", text: $title).font(.system(size: 16)).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))
@@ -409,13 +684,13 @@ struct AddKnowledgePage: View {
ForEach(selectedFiles) { f in
HStack(spacing: 8) {
Image(systemName: f.icon).foregroundColor(Color.zxGreen)
- Text(f.name).font(.system(size: 13)).foregroundColor(Color.zxF0).lineLimit(1)
+ Text(f.name).font(.system(size: 14)).foregroundColor(Color.zxF0).lineLimit(1)
Spacer()
- Text(f.size).font(.system(size: 11)).foregroundColor(Color.zxF04)
+ Text(f.size).font(.system(size: 12)).foregroundColor(Color.zxF04)
Button {
selectedFiles.removeAll { $0.id == f.id }
} label: {
- Image(systemName: "xmark.circle.fill").font(.system(size: 16)).foregroundColor(Color.zxF04)
+ Image(systemName: "xmark.circle").font(.system(size: 16)).foregroundColor(Color.zxF04)
}
}
.padding(.horizontal, 12).padding(.vertical, 8)
@@ -426,16 +701,16 @@ struct AddKnowledgePage: View {
}
HStack {
- Image(systemName: "info.circle").font(.system(size: 11))
+ Image(systemName: "info.circle").font(.system(size: 12))
Text("支持多选,每个文件生成一个知识点")
}
- .font(.system(size: 11)).foregroundColor(Color.zxF04)
+ .font(.system(size: 12)).foregroundColor(Color.zxF04)
}
if isUploading {
HStack(spacing: 8) {
ProgressView()
- Text("上传中 \(currentUploadIndex)/\(selectedFiles.count)...").font(.system(size: 13)).foregroundColor(Color.zxF04)
+ Text("上传中 \(currentUploadIndex)/\(selectedFiles.count)...").font(.system(size: 14)).foregroundColor(Color.zxF04)
}
}
}
@@ -455,7 +730,7 @@ struct AddKnowledgePage: View {
.disabled(!canSave || isSaving)
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80) }.scrollIndicators(.hidden) }
}
- .navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).hideTabBarWithAnimation()
+ .navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar)
.fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.plainText, .pdf, .image], allowsMultipleSelection: true) { result in
if case .success(let urls) = result { handleFiles(urls) }
}
@@ -590,7 +865,7 @@ struct AddKnowledgePage: View {
let ext = name.lowercased()
if ext.hasSuffix(".md") || ext.hasSuffix(".markdown") { return "doc.richtext" }
if ext.hasSuffix(".txt") { return "doc.plaintext" }
- if ext.hasSuffix(".pdf") { return "doc.fill" }
+ if ext.hasSuffix(".pdf") { return "doc" }
if ext.hasSuffix(".jpg") || ext.hasSuffix(".jpeg") || ext.hasSuffix(".png") || ext.hasSuffix(".heic") { return "photo" }
return "doc"
}
@@ -639,11 +914,11 @@ struct KnowledgeDetailPage: View {
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(value: Route.aiChat) {
- 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))
+ Label("费曼解释", systemImage: "mic").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).hideTabBarWithAnimation()}
+ }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar)}
}
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()) }
@@ -664,16 +939,16 @@ struct ImportPage: View {
ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
ScrollView { VStack(spacing: 12) {
if let error = importError {
- HStack(spacing: 8) { Image("icon-warning").foregroundColor(.red); Text(error).font(.system(size: 13)).foregroundColor(.red) }
+ HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle").foregroundColor(.red); Text(error).font(.system(size: 14)).foregroundColor(.red) }
.padding(12)
}
if !statusMessage.isEmpty {
- HStack(spacing: 8) { ProgressView(); Text(statusMessage).font(.system(size: 13)).foregroundColor(Color.zxF04) }
+ HStack(spacing: 8) { ProgressView(); Text(statusMessage).font(.system(size: 14)).foregroundColor(Color.zxF04) }
.padding(12)
}
Button { showFilePicker = true } label: {
- ZXImportRow(icon: "doc.text.fill", title: "文件导入", desc: "支持 PDF、Word、Markdown、TXT")
+ ZXImportRow(icon: "doc.text", title: "文件导入", desc: "支持 PDF、Word、Markdown、TXT")
}
Button { showPhotoPicker = true } label: {
ZXImportRow(icon: "photo.on.rectangle", title: "相册导入", desc: "从相册选择截图或图片,AI 自动识别文字")
@@ -683,7 +958,7 @@ struct ImportPage: View {
}
}.padding(.horizontal, 20).padding(.top, 8) }.scrollIndicators(.hidden) }
}
- .navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).hideTabBarWithAnimation()
+ .navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar)
.disabled(isImporting)
.task { do { let kbs = try await KnowledgeBaseService.shared.list(page: 1, limit: 1); kbId = kbs.first?.id } catch {} }
.fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.pdf, .plainText], allowsMultipleSelection: true) { result in
@@ -759,7 +1034,7 @@ struct ImportPage: View {
struct ZXImportRow: View { let icon: String; let title: String; let desc: String
var body: some View { HStack(spacing: 14) { Image(systemName: icon).font(.system(size: 22)).foregroundColor(Color.zxPurple).frame(width: 48, height: 48)
-; 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("icon-chevron-right").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(Color.zxF03) }.padding(16).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 16)).overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1)) }
+; VStack(alignment: .leading, spacing: 2) { Text(title).font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF04) }; Spacer(); Image("icon-chevron-right").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(Color.zxF03) }.padding(16).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 16)).overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1)) }
}
// MARK: - Import Review
@@ -776,7 +1051,7 @@ struct ImportReviewPage: View {
var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
if isLoading {
- VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载候选...").font(.system(size: 13)).foregroundColor(Color.zxF04) }
+ VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载候选...").font(.system(size: 14)).foregroundColor(Color.zxF04) }
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if candidates.isEmpty {
VStack(spacing: 12) {
@@ -787,12 +1062,12 @@ struct ImportReviewPage: View {
ScrollView {
VStack(spacing: 12) {
HStack {
- Text("\(candidates.count) 个候选知识点").font(.system(size: 13, weight: .medium)).foregroundColor(Color.zxF04)
+ Text("\(candidates.count) 个候选知识点").font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxF04)
Spacer()
Button {
Task { await batchAccept() }
} label: {
- Text("全部接受").font(.system(size: 13, weight: .semibold)).foregroundColor(Color.zxPrimary)
+ Text("全部接受").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxPrimary)
}
.disabled(isProcessing)
}
@@ -827,7 +1102,7 @@ struct ImportReviewPage: View {
Button {
Task { await rejectCandidate(c) }
} label: {
- Label("拒绝", systemImage: "xmark").font(.system(size: 13, weight: .medium))
+ Label("拒绝", systemImage: "xmark").font(.system(size: 14, weight: .medium))
.foregroundColor(Color.zxCoral).frame(maxWidth: .infinity).frame(height: 40)
@@ -835,7 +1110,7 @@ struct ImportReviewPage: View {
Button {
Task { await acceptCandidate(c) }
} label: {
- Label("接受", systemImage: "checkmark").font(.system(size: 13, weight: .medium))
+ Label("接受", systemImage: "checkmark").font(.system(size: 14, weight: .medium))
.foregroundColor(Color.zxGreen).frame(maxWidth: .infinity).frame(height: 40)
@@ -851,7 +1126,7 @@ struct ImportReviewPage: View {
.scrollIndicators(.hidden)
}
}}
- .navigationTitle("候选审批").navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).hideTabBarWithAnimation()
+ .navigationTitle("候选审批").navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar)
.disabled(isProcessing)
.task { await load() }
}
@@ -905,12 +1180,12 @@ struct EditKnowledgePage: View {
var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
ScrollView { VStack(spacing: 16) {
- VStack(alignment: .leading, spacing: 8) { Text("标题").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextField("", text: $title).font(.system(size: 15)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 52).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) }
+ VStack(alignment: .leading, spacing: 8) { Text("标题").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextField("", text: $title).font(.system(size: 16)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 52).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) }
VStack(alignment: .leading, spacing: 8) { Text("内容").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextEditor(text: $content).frame(minHeight: 200).scrollContentBackground(.hidden).padding(12).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) }
Button {
Task { _ = try? await KnowledgeItemService.shared.update(id: item.id, title: title, content: content, summary: nil) }
} label: { Text("保存修改").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) }
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80) }.scrollIndicators(.hidden) }
- }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).hideTabBarWithAnimation()
+ }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar)
}
}
diff --git a/AIStudyApp/AIStudyApp/Features/Library/LibraryViewModel.swift b/AIStudyApp/AIStudyApp/Features/Library/LibraryViewModel.swift
index 2c26b0c..bddbfec 100644
--- a/AIStudyApp/AIStudyApp/Features/Library/LibraryViewModel.swift
+++ b/AIStudyApp/AIStudyApp/Features/Library/LibraryViewModel.swift
@@ -97,7 +97,11 @@ class LibraryDetailViewModel: ObservableObject {
errorMessage = nil
currentPage = 1
do {
- items = try await KnowledgeItemService.shared.list(knowledgeBaseId: knowledgeBaseId)
+ async let kb = try? KnowledgeBaseService.shared.detail(id: knowledgeBaseId)
+ async let list = try? KnowledgeItemService.shared.list(knowledgeBaseId: knowledgeBaseId)
+ let (kbResult, listResult) = await (kb, list)
+ knowledgeBase = kbResult
+ items = listResult ?? []
hasMore = items.count >= pageSize
} catch {
if items.isEmpty { errorMessage = "加载知识点失败" }
diff --git a/AIStudyApp/AIStudyApp/Features/Profile/EditProfilePage.swift b/AIStudyApp/AIStudyApp/Features/Profile/EditProfilePage.swift
index 67db13b..4802e12 100644
--- a/AIStudyApp/AIStudyApp/Features/Profile/EditProfilePage.swift
+++ b/AIStudyApp/AIStudyApp/Features/Profile/EditProfilePage.swift
@@ -51,7 +51,7 @@ struct EditProfilePage: View {
showPhotoPicker = true
} label: {
Text(isUploadingAvatar ? "上传中..." : "更换头像")
- .font(.system(size: 13, weight: .medium))
+ .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.zxPrimary)
}
.disabled(isUploadingAvatar)
@@ -94,7 +94,7 @@ struct EditProfilePage: View {
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
if let error = saveError {
- Text(error).font(.system(size: 13)).foregroundColor(.red)
+ Text(error).font(.system(size: 14)).foregroundColor(.red)
.padding(.horizontal, 16).padding(.vertical, 10)
.background(Color.red.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 10))
@@ -142,7 +142,7 @@ struct EditProfilePage: View {
}
.navigationTitle("编辑资料")
.navigationBarTitleDisplayMode(.inline)
- .hideTabBarWithAnimation()
+ .toolbar(.hidden, for: .tabBar)
.toolbarBackground(.hidden, for: .navigationBar)
.photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItem, matching: .images)
.onChange(of: selectedPhotoItem) { _, item in
diff --git a/AIStudyApp/AIStudyApp/Features/Profile/FeedbackFormView.swift b/AIStudyApp/AIStudyApp/Features/Profile/FeedbackFormView.swift
index a9b5481..0b4b1a8 100644
--- a/AIStudyApp/AIStudyApp/Features/Profile/FeedbackFormView.swift
+++ b/AIStudyApp/AIStudyApp/Features/Profile/FeedbackFormView.swift
@@ -39,6 +39,6 @@ struct FeedbackFormView: View {
.disabled(isSubmitting)
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80)
}.scrollIndicators(.hidden)
- }.navigationBarTitleDisplayMode(.inline).hideTabBarWithAnimation().toolbarBackground(.hidden, for: .navigationBar)
+ }.navigationBarTitleDisplayMode(.inline).toolbar(.hidden, for: .tabBar).toolbarBackground(.hidden, for: .navigationBar)
}
}
diff --git a/AIStudyApp/AIStudyApp/Features/Profile/GoalSettingDetailView.swift b/AIStudyApp/AIStudyApp/Features/Profile/GoalSettingDetailView.swift
index 6338444..1d9ae27 100644
--- a/AIStudyApp/AIStudyApp/Features/Profile/GoalSettingDetailView.swift
+++ b/AIStudyApp/AIStudyApp/Features/Profile/GoalSettingDetailView.swift
@@ -18,7 +18,7 @@ struct GoalSettingDetailView: View {
Button { selectedGoal = g.1 } label: {
HStack(spacing: 12) {
Image(systemName: g.0).font(.system(size: 20)).foregroundColor(sel ? Color.zxPurple : Color.zxF05).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) }
+ VStack(alignment: .leading, spacing: 2) { Text(g.1).font(.system(size: 16, 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))
@@ -49,6 +49,6 @@ struct GoalSettingDetailView: View {
.disabled(isSaving)
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80)
}.scrollIndicators(.hidden)
- }.navigationBarTitleDisplayMode(.inline).hideTabBarWithAnimation().toolbarBackground(.hidden, for: .navigationBar)
+ }.navigationBarTitleDisplayMode(.inline).toolbar(.hidden, for: .tabBar).toolbarBackground(.hidden, for: .navigationBar)
}
}
diff --git a/AIStudyApp/AIStudyApp/Features/Profile/MethodPreferenceView.swift b/AIStudyApp/AIStudyApp/Features/Profile/MethodPreferenceView.swift
index e5344d8..17fe263 100644
--- a/AIStudyApp/AIStudyApp/Features/Profile/MethodPreferenceView.swift
+++ b/AIStudyApp/AIStudyApp/Features/Profile/MethodPreferenceView.swift
@@ -16,8 +16,8 @@ struct MethodPreferenceView: View {
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)
+ Image(systemName: sel ? "checkmark.circle" : "circle").font(.system(size: 20)).foregroundColor(sel ? Color.zxPurple : Color.zxF02)
+ Text(m).font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0)
Spacer()
}.padding(14).background(sel ? Color(hex: "#7C6EFA", opacity: 0.08) : Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 14))
}.foregroundColor(.primary)
@@ -46,6 +46,6 @@ struct MethodPreferenceView: View {
.disabled(isSaving)
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80)
}.scrollIndicators(.hidden)
- }.navigationBarTitleDisplayMode(.inline).hideTabBarWithAnimation().toolbarBackground(.hidden, for: .navigationBar)
+ }.navigationBarTitleDisplayMode(.inline).toolbar(.hidden, for: .tabBar).toolbarBackground(.hidden, for: .navigationBar)
}
}
diff --git a/AIStudyApp/AIStudyApp/Features/Profile/NotificationListView.swift b/AIStudyApp/AIStudyApp/Features/Profile/NotificationListView.swift
index 4d59f3d..9cdd3dd 100644
--- a/AIStudyApp/AIStudyApp/Features/Profile/NotificationListView.swift
+++ b/AIStudyApp/AIStudyApp/Features/Profile/NotificationListView.swift
@@ -21,7 +21,7 @@ struct NotificationListView: View {
ScrollView {
VStack(spacing: 0) {
if isLoading && notifications.isEmpty {
- VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载中…").font(.system(size: 13)).foregroundColor(Color.zxF04) }.padding(.top, 120)
+ VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04) }.padding(.top, 120)
} else if notifications.isEmpty {
VStack(spacing: 12) { Image("icon-bell-off").font(.system(size: 40)).foregroundColor(Color.zxF03); Text("暂无通知").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF03) }.padding(.top, 120)
} else {
@@ -32,10 +32,10 @@ struct NotificationListView: View {
}.padding(.bottom, 100)
}
.scrollIndicators(.hidden)
- .zxPullToRefresh { await refresh() }
+ .refreshable { await refresh() }
}
.navigationTitle("消息中心")
- .navigationBarTitleDisplayMode(.inline).hideTabBarWithAnimation()
+ .navigationBarTitleDisplayMode(.inline).toolbar(.hidden, for: .tabBar)
.toolbarBackground(.hidden, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
@@ -45,7 +45,7 @@ struct NotificationListView: View {
await loadNotifications()
}
} label: {
- Text("全部已读").font(.system(size: 13))
+ Text("全部已读").font(.system(size: 14))
}
}
}
@@ -109,10 +109,10 @@ struct ZXNotificationItemRow: View {
case "import_failed": return "doc.fill.badge.xmark"
case "quiz_ready": return "questionmark.circle"
case "ai_analysis", "ai_complete": return "brain.head.profile"
- case "streak": return "flame.fill"
- case "subscription", "subscription_update": return "bell.fill"
+ case "streak": return "flame"
+ case "subscription", "subscription_update": return "bell"
case "system": return "info.circle"
- default: return "bell.fill"
+ default: return "bell"
}
}
diff --git a/AIStudyApp/AIStudyApp/Features/Profile/ProfileView.swift b/AIStudyApp/AIStudyApp/Features/Profile/ProfileView.swift
index 09a29b0..6d41197 100644
--- a/AIStudyApp/AIStudyApp/Features/Profile/ProfileView.swift
+++ b/AIStudyApp/AIStudyApp/Features/Profile/ProfileView.swift
@@ -27,15 +27,15 @@ struct ProfileView: View {
}.foregroundColor(.primary)
ZXProfileDivider()
NavigationLink(value: Route.settings) {
- ZXProfileMenuRow(icon: "bell.fill", title: "复习提醒", desc: "间隔复习通知设置")
+ ZXProfileMenuRow(icon: "bell", title: "复习提醒", desc: "间隔复习通知设置")
}.foregroundColor(.primary)
ZXProfileDivider()
NavigationLink(value: Route.methodPreference) {
- ZXProfileMenuRow(icon: "puzzlepiece.fill", title: "学习方法偏好", desc: "回忆 · 费曼 · 间隔")
+ ZXProfileMenuRow(icon: "puzzlepiece", title: "学习方法偏好", desc: "回忆 · 费曼 · 间隔")
}.foregroundColor(.primary)
ZXProfileDivider()
NavigationLink(value: Route.feedbackForm) {
- ZXProfileMenuRow(icon: "bubble.left.fill", title: "帮助与反馈", desc: "问题报告 · 功能建议")
+ ZXProfileMenuRow(icon: "bubble.left", title: "帮助与反馈", desc: "问题报告 · 功能建议")
}.foregroundColor(.primary)
}.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20)).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
assetsSection.padding(.bottom, 120)
@@ -117,7 +117,7 @@ struct ProfileView: View {
VStack(alignment: .leading, spacing: 2) {
Text("存储空间").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0)
- Text(viewModel.formattedStorage).font(.system(size: 11)).foregroundColor(Color.zxF04)
+ Text(viewModel.formattedStorage).font(.system(size: 12)).foregroundColor(Color.zxF04)
}
Spacer(); Image("icon-chevron-right").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(Color.zxF03)
}.padding(.horizontal, 16).padding(.vertical, 14)
@@ -125,11 +125,11 @@ struct ProfileView: View {
}
}
}
-struct ZXProfileStat: View { let v: String; let l: String; let c: Color; var body: some View { VStack(spacing: 2) { Text(v).font(.system(size: 18, weight: .bold)).foregroundColor(c); Text(l).font(.system(size: 11)).foregroundColor(Color.zxF04) }.frame(maxWidth: .infinity) }
+struct ZXProfileStat: View { let v: String; let l: String; let c: Color; var body: some View { VStack(spacing: 2) { Text(v).font(.system(size: 18, weight: .bold)).foregroundColor(c); Text(l).font(.system(size: 12)).foregroundColor(Color.zxF04) }.frame(maxWidth: .infinity) }
init(value: String, label: String, color: Color) { self.v = value; self.l = label; self.c = color }
}
struct ZXProfileMenuRow: View { let icon: String; let title: String; let desc: String
- var body: some View { HStack(spacing: 12) { Image(systemName: icon).font(.system(size: 18)).foregroundColor(Color.zxF05).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("icon-chevron-right").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(Color.zxF03) }.padding(.horizontal, 16).padding(.vertical, 14).accessibilityLabel("\(title):\(desc)") }
+ var body: some View { HStack(spacing: 12) { Image(systemName: icon).font(.system(size: 18)).foregroundColor(Color.zxF05).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: 12)).foregroundColor(Color.zxF03) }; Spacer(); Image("icon-chevron-right").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(Color.zxF03) }.padding(.horizontal, 16).padding(.vertical, 14).accessibilityLabel("\(title):\(desc)") }
}
struct ZXProfileDivider: View {
var body: some View { Rectangle().fill(Color.zxBorder008).frame(height: 1).padding(.leading, 64) }
diff --git a/AIStudyApp/AIStudyApp/Features/Profile/SettingsView.swift b/AIStudyApp/AIStudyApp/Features/Profile/SettingsView.swift
index a1ade3a..367aa60 100644
--- a/AIStudyApp/AIStudyApp/Features/Profile/SettingsView.swift
+++ b/AIStudyApp/AIStudyApp/Features/Profile/SettingsView.swift
@@ -115,7 +115,7 @@ struct SettingsView: View {
notificationEnabled = p.notificationEnabled ?? true
reviewReminder = notificationEnabled
}
- .navigationBarTitleDisplayMode(.inline).hideTabBarWithAnimation().toolbarBackground(.hidden, for: .navigationBar)
+ .navigationBarTitleDisplayMode(.inline).toolbar(.hidden, for: .tabBar).toolbarBackground(.hidden, for: .navigationBar)
}
private func sectionHeader(_ text: String) -> some View {
@@ -162,7 +162,7 @@ struct ZXSettingRow: View {
else { Image(systemName: icon).font(.system(size: 18)).foregroundColor(color) }
Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0)
Spacer()
- if !value.isEmpty { Text(value).font(.system(size: 13)).foregroundColor(Color.zxF03) }
+ if !value.isEmpty { Text(value).font(.system(size: 14)).foregroundColor(Color.zxF03) }
Image("icon-chevron-right").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(Color.zxF03)
}.padding(.horizontal, 16).padding(.vertical, 14)
}
diff --git a/AIStudyApp/AIStudyApp/Features/Quiz/QuizViews.swift b/AIStudyApp/AIStudyApp/Features/Quiz/QuizViews.swift
index 8e4e191..1bf7f30 100644
--- a/AIStudyApp/AIStudyApp/Features/Quiz/QuizViews.swift
+++ b/AIStudyApp/AIStudyApp/Features/Quiz/QuizViews.swift
@@ -13,7 +13,7 @@ struct QuizListView: View {
Color.zxBg0.ignoresSafeArea()
VStack(spacing: 0) {
if isLoading {
- VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载中…").font(.system(size: 13)).foregroundColor(Color.zxF04) }.frame(maxWidth: .infinity, maxHeight: .infinity)
+ VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04) }.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if quizzes.isEmpty {
VStack(spacing: 16) {
Image("icon-question").font(.system(size: 40)).foregroundColor(Color.zxF03)
@@ -38,10 +38,10 @@ struct QuizListView: View {
ForEach(quizzes) { q in
NavigationLink(value: Route.quizTake(quizId: q.id)) {
VStack(alignment: .leading, spacing: 8) {
- HStack { Text(q.title ?? "测验").font(.system(size: 15, weight: .semibold)).foregroundColor(Color.zxF0); Spacer(); Image("icon-chevron-right").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(Color.zxF03) }
+ HStack { Text(q.title ?? "测验").font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0); Spacer(); Image("icon-chevron-right").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(Color.zxF03) }
HStack(spacing: 12) {
- Label("\(q.questionCount ?? 0) 题", systemImage: "list.bullet").font(.system(size: 11)).foregroundColor(Color.zxF04)
- Label("选择题/判断/填空", systemImage: "square.grid.3x3").font(.system(size: 11)).foregroundColor(Color.zxF04)
+ Label("\(q.questionCount ?? 0) 题", systemImage: "list.bullet").font(.system(size: 12)).foregroundColor(Color.zxF04)
+ Label("选择题/判断/填空", systemImage: "square.grid.3x3").font(.system(size: 12)).foregroundColor(Color.zxF04)
}
}.padding(14).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 14))
}.foregroundColor(.primary)
@@ -51,7 +51,7 @@ struct QuizListView: View {
}
}
}
- .navigationTitle("测验").navigationBarTitleDisplayMode(.inline).hideTabBarWithAnimation().toolbarBackground(.hidden, for: .navigationBar)
+ .navigationTitle("测验").navigationBarTitleDisplayMode(.inline).toolbar(.hidden, for: .tabBar).toolbarBackground(.hidden, for: .navigationBar)
.task { await load() }
}
@@ -88,13 +88,13 @@ struct QuizTakerView: View {
ZStack {
Color.zxBg0.ignoresSafeArea()
if isLoading {
- VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载测验…").font(.system(size: 13)).foregroundColor(Color.zxF04) }.frame(maxWidth: .infinity, maxHeight: .infinity)
+ VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载测验…").font(.system(size: 14)).foregroundColor(Color.zxF04) }.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let q = quiz, let questions = q.questions, !questions.isEmpty {
VStack(spacing: 0) {
// Progress
VStack(spacing: 8) {
HStack {
- Text("第 \(currentIndex + 1) / \(questions.count) 题").font(.system(size: 13, weight: .medium)).foregroundColor(Color.zxF04)
+ Text("第 \(currentIndex + 1) / \(questions.count) 题").font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxF04)
Spacer()
Text("已答 \(answers.count) 题").font(.system(size: 12)).foregroundColor(Color.zxF03)
}
@@ -119,7 +119,7 @@ struct QuizTakerView: View {
answers[question.id] = String(i)
} label: {
HStack(spacing: 12) {
- Text(["A","B","C","D"][i]).font(.system(size: 13, weight: .bold)).foregroundColor(answers[question.id] == String(i) ? .white : Color.zxPurple).frame(width: 30, height: 30).background(answers[question.id] == String(i) ? Color.zxPurple : Color.zxPurpleBG(0.12)).clipShape(Circle())
+ Text(["A","B","C","D"][i]).font(.system(size: 14, weight: .bold)).foregroundColor(answers[question.id] == String(i) ? .white : Color.zxPurple).frame(width: 30, height: 30).background(answers[question.id] == String(i) ? Color.zxPurple : Color.zxPurpleBG(0.12)).clipShape(Circle())
Text(opt).font(.system(size: 14)).foregroundColor(Color.zxF0)
Spacer()
}.padding(12).background(answers[question.id] == String(i) ? Color.zxPurpleBG(0.08) : Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 12)).overlay(RoundedRectangle(cornerRadius: 12).stroke(answers[question.id] == String(i) ? Color.zxPurple.opacity(0.2) : Color.clear, lineWidth: 1))
@@ -164,7 +164,7 @@ struct QuizTakerView: View {
}
}
}
- .navigationBarTitleDisplayMode(.inline).hideTabBarWithAnimation().toolbarBackground(.hidden, for: .navigationBar)
+ .navigationBarTitleDisplayMode(.inline).toolbar(.hidden, for: .tabBar).toolbarBackground(.hidden, for: .navigationBar)
.task { await load() }
}
@@ -201,26 +201,26 @@ struct QuizResultView: View {
ZStack {
Color.zxBg0.ignoresSafeArea()
if isLoading {
- VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载结果…").font(.system(size: 13)).foregroundColor(Color.zxF04) }.frame(maxWidth: .infinity, maxHeight: .infinity)
+ VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载结果…").font(.system(size: 14)).foregroundColor(Color.zxF04) }.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let r = result {
ScrollView {
VStack(spacing: 16) {
VStack(spacing: 12) {
Text("\(r.score ?? 0)分").font(.system(size: 48, weight: .heavy)).foregroundColor(r.score ?? 0 >= 60 ? Color.zxGreen : Color.zxCoral)
- Text("答对 \(r.correctCount ?? 0)/\(r.totalQuestions ?? 0) 题").font(.system(size: 15)).foregroundColor(Color.zxF05)
+ Text("答对 \(r.correctCount ?? 0)/\(r.totalQuestions ?? 0) 题").font(.system(size: 16)).foregroundColor(Color.zxF05)
NavigationLink(value: Route.quizTake(quizId: quizId)) {
Text("重新测验").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 48).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: 14))
}.padding(.horizontal, 20)
}.padding(24).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20)).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
VStack(alignment: .leading, spacing: 12) {
- Text("答题详情").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0)
+ Text("答题详情").font(.system(size: 16, weight: .bold)).foregroundColor(Color.zxF0)
ForEach(r.answers ?? []) { a in
HStack(spacing: 12) {
- Image(systemName: a.isCorrect == true ? "checkmark.circle.fill" : "xmark.circle.fill").font(.system(size: 18)).foregroundColor(a.isCorrect == true ? Color.zxGreen : Color.zxCoral)
+ Image(systemName: a.isCorrect == true ? "checkmark.circle" : "xmark.circle").font(.system(size: 18)).foregroundColor(a.isCorrect == true ? Color.zxGreen : Color.zxCoral)
VStack(alignment: .leading, spacing: 4) {
- Text(a.question?.stem ?? "").font(.system(size: 13)).foregroundColor(Color.zxF0).lineLimit(2)
- if let exp = a.question?.explanation { Text(exp).font(.system(size: 11)).foregroundColor(Color.zxF04).lineLimit(2) }
+ Text(a.question?.stem ?? "").font(.system(size: 14)).foregroundColor(Color.zxF0).lineLimit(2)
+ if let exp = a.question?.explanation { Text(exp).font(.system(size: 12)).foregroundColor(Color.zxF04).lineLimit(2) }
}
Spacer()
}.padding(12).background(a.isCorrect == true ? Color.zxGreen.opacity(0.05) : Color.zxCoral.opacity(0.05)).clipShape(RoundedRectangle(cornerRadius: 12))
@@ -230,7 +230,7 @@ struct QuizResultView: View {
}.scrollIndicators(.hidden)
}
}
- .navigationBarTitleDisplayMode(.inline).hideTabBarWithAnimation().toolbarBackground(.hidden, for: .navigationBar)
+ .navigationBarTitleDisplayMode(.inline).toolbar(.hidden, for: .tabBar).toolbarBackground(.hidden, for: .navigationBar)
.task { await load() }
}
diff --git a/AIStudyApp/AIStudyApp/Features/Study/LearningSessionView.swift b/AIStudyApp/AIStudyApp/Features/Study/LearningSessionView.swift
index 8bed198..e1cc257 100644
--- a/AIStudyApp/AIStudyApp/Features/Study/LearningSessionView.swift
+++ b/AIStudyApp/AIStudyApp/Features/Study/LearningSessionView.swift
@@ -36,7 +36,7 @@ struct LearningSessionView: View {
bottomBar
}.ignoresSafeArea(edges: .bottom)
}
- .navigationBarTitleDisplayMode(.inline).hideTabBarWithAnimation()
+ .navigationBarTitleDisplayMode(.inline).toolbar(.hidden, for: .tabBar)
.toolbarBackground(.hidden, for: .navigationBar)
.onReceive(timer) { _ in
if isRunning { elapsed += 1 }
@@ -83,7 +83,7 @@ struct LearningSessionView: View {
.tracking(-1)
.contentTransition(.numericText())
Text("已学习")
- .font(.system(size: 13, weight: .medium))
+ .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.zxF04)
}
}
@@ -97,7 +97,7 @@ struct LearningSessionView: View {
if isRunning { isPaused = true; isRunning = false }
else { isPaused = false; isRunning = true }
} label: {
- Label(isRunning ? "暂停" : "继续", systemImage: isRunning ? "pause.fill" : "icon-play")
+ Label(isRunning ? "暂停" : "继续", systemImage: isRunning ? "pause" : "play")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.white)
.frame(maxWidth: .infinity).frame(height: 48)
@@ -107,7 +107,7 @@ struct LearningSessionView: View {
.zxPressable()
.accessibilityLabel(isRunning ? "暂停学习" : "继续学习")
Button { showEndConfirm = true } label: {
- Label("结束", systemImage: "stop.fill")
+ Label("结束", systemImage: "stop")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.zxF05)
.frame(maxWidth: .infinity).frame(height: 48)
@@ -128,9 +128,9 @@ struct LearningSessionView: View {
private var sessionInfoCard: some View {
VStack(spacing: 0) {
- ZXSessionInfoRow(icon: "doc.text.fill", label: "当前任务", value: taskTitle, color: taskColor)
+ ZXSessionInfoRow(icon: "doc.text", label: "当前任务", value: taskTitle, color: taskColor)
ZXSessionDivider()
- ZXSessionInfoRow(icon: "tag.fill", label: "任务类型", value: taskType, color: taskColor)
+ ZXSessionInfoRow(icon: "tag", label: "任务类型", value: taskType, color: taskColor)
ZXSessionDivider()
ZXSessionInfoRow(icon: "target", label: "建议时长", value: "30 分钟", color: Color(hex: "#7C6EFA"))
ZXSessionDivider()
@@ -144,8 +144,8 @@ struct LearningSessionView: View {
private var tipsCard: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
- Image("icon-lightbulb").font(.system(size: 14)).foregroundColor(Color.zxYellow)
- Text("学习小贴士").font(.system(size: 13, weight: .bold)).foregroundColor(Color.zxF0)
+ Image(systemName: "lightbulb").font(.system(size: 14)).foregroundColor(Color.zxYellow)
+ Text("学习小贴士").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0)
}
Text("保持专注,25-30 分钟后休息 5 分钟能有效提升记忆效果。学习时尽量避免切换任务。")
.zxFontScaled(size: 12).foregroundColor(Color.zxF04).lineSpacing(4)
@@ -189,9 +189,9 @@ struct ZXSessionInfoRow: 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)
+ Text(label).font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxF04)
Spacer()
- Text(value).font(.system(size: 13, weight: .semibold)).foregroundColor(Color.zxF0).lineLimit(1)
+ Text(value).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0).lineLimit(1)
}.padding(.horizontal, 16).padding(.vertical, 14)
}
}
diff --git a/AIStudyApp/AIStudyApp/Features/Study/ReviewCardView.swift b/AIStudyApp/AIStudyApp/Features/Study/ReviewCardView.swift
index 8c188c3..389d18d 100644
--- a/AIStudyApp/AIStudyApp/Features/Study/ReviewCardView.swift
+++ b/AIStudyApp/AIStudyApp/Features/Study/ReviewCardView.swift
@@ -40,7 +40,7 @@ struct ReviewCardView: View {
.scrollIndicators(.hidden)
}
}
- .navigationBarTitleDisplayMode(.inline).hideTabBarWithAnimation()
+ .navigationBarTitleDisplayMode(.inline).toolbar(.hidden, for: .tabBar)
.toolbarBackground(.hidden, for: .navigationBar)
.task { await viewModel.loadDueCards() }
.overlay {
@@ -124,7 +124,7 @@ struct ReviewCardView: View {
private var ratingBar: some View {
VStack(spacing: 10) {
- Text("你的掌握程度?").font(.system(size: 13, weight: .semibold)).foregroundColor(Color.zxF04)
+ Text("你的掌握程度?").font(.system(size: 14, 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() }
@@ -163,7 +163,7 @@ struct ZXRatingBtn: View {
var body: some View {
Button(action: action) {
VStack(spacing: 4) {
- Text(label).font(.system(size: 11, weight: selected ? .bold : .medium))
+ Text(label).font(.system(size: 12, weight: selected ? .bold : .medium))
.foregroundColor(selected ? .white : color)
}
.frame(maxWidth: .infinity).frame(height: 56)
diff --git a/AIStudyApp/AIStudyApp/Features/Study/StudyHomeView.swift b/AIStudyApp/AIStudyApp/Features/Study/StudyHomeView.swift
index 65bced1..01e9a80 100644
--- a/AIStudyApp/AIStudyApp/Features/Study/StudyHomeView.swift
+++ b/AIStudyApp/AIStudyApp/Features/Study/StudyHomeView.swift
@@ -1,136 +1,214 @@
import SwiftUI
struct StudyHomeView: View {
- @StateObject private var studyHomeVM = StudyHomeViewModel()
- @StateObject private var reviewVM = ReviewViewModel()
- @State private var streakDays = 0
- @State private var weeklyMinutes = 0
- @State private var reviewCount = 0
- @State private var hasTodayReview = false
+ @Binding var selectedTab: String
+ @StateObject private var vm = StudyHomeViewModel()
var body: some View {
ZStack {
Color.zxCanvas.ignoresSafeArea()
- ScrollView {
- VStack(spacing: 16) {
- // Header + streak
- HStack {
- Spacer()
- HStack(spacing: 4) {
- Image("icon-flame")
- .font(.system(size: 14)).foregroundColor(Color.zxOrange)
- Text("\(streakDays) 天连续")
- .font(.system(size: 13, weight: .bold)).foregroundColor(Color.zxOrange)
- }
- .padding(.horizontal, 12).padding(.vertical, 6)
- .background(Color.zxOrangeBG(0.1)).clipShape(Capsule())
- .overlay(Capsule().stroke(Color(hex: "#F97316", opacity: 0.2), lineWidth: 1))
- }
+ switch vm.loadingState {
+ case .idle, .loading:
+ loadingView
+ case .error(let msg):
+ errorView(msg)
+ case .loaded:
+ contentView
+ }
+ }
+ .task { await vm.loadAll() }
+ }
+
+ // MARK: - Loading / Error
+
+ private var loadingView: some View {
+ ProgressView()
+ .tint(Color.zxPrimary)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ private func errorView(_ msg: String) -> some View {
+ VStack(spacing: 16) {
+ Image(systemName: "exclamationmark.triangle")
+ .font(.system(size: 40)).foregroundColor(Color.zxF03)
+ Text(msg)
+ .font(.system(size: 14)).foregroundColor(Color.zxF04)
+ .multilineTextAlignment(.center)
+ Button {
+ Task { await vm.loadAll() }
+ } label: {
+ Text("重试")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundColor(.white)
+ .frame(height: 44).padding(.horizontal, 32)
+ .background(ZXGradient.brand)
+ .clipShape(RoundedRectangle(cornerRadius: ZXRadius.md))
+ }
+ }
+ .padding(.horizontal, 40)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ // MARK: - Content
+
+ private var contentView: some View {
+ ScrollView {
+ VStack(spacing: 16) {
+ streakHeader
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 4)
- // MARK: - Main Action Card
- if hasTodayReview {
- mainActionCard(
- icon: "arrow.triangle.2.circlepath",
- title: "今日复习",
- subtitle: "\(reviewCount) 张卡片待复习",
- color: Color.zxPurple,
- route: Route.reviewCard
- )
- } else {
- mainActionCard(
- icon: "sparkles",
- title: "开始学习",
- subtitle: "从知识库挑选内容开始今天的进步",
- color: Color.zxPrimary,
- route: Route.studyHome
- )
- }
-
- // MARK: - Weekly Summary
- weeklySummaryCard
-
- // MARK: - Quick Actions
- HStack(spacing: 12) {
- NavigationLink(value: Route.aiChat) {
- VStack(spacing: 6) {
- Image("icon-sparkles").font(.system(size: 18)).foregroundColor(Color.zxPurple).frame(width: 44, height: 44)
-
- Text("AI 问答").font(.system(size: 11)).foregroundColor(Color.zxF04)
- }.frame(maxWidth: .infinity)
- }.foregroundColor(.primary)
- NavigationLink(value: Route.activeRecall) {
- VStack(spacing: 6) {
- Image("icon-brain").font(.system(size: 18)).foregroundColor(Color.zxOrange).frame(width: 44, height: 44)
-
- Text("自测").font(.system(size: 11)).foregroundColor(Color.zxF04)
- }.frame(maxWidth: .infinity)
- }.foregroundColor(.primary)
- NavigationLink(value: Route.reviewCard) {
- VStack(spacing: 6) {
- Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 18)).foregroundColor(Color.zxTeal).frame(width: 44, height: 44)
-
- Text("复习").font(.system(size: 11)).foregroundColor(Color.zxF04)
- }.frame(maxWidth: .infinity)
- }.foregroundColor(.primary)
- }.padding(.horizontal, 20)
-
- // MARK: - Today Tasks
- VStack(alignment: .leading, spacing: 12) {
- HStack {
- Text("今日任务").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0)
- Spacer()
- HStack(spacing: 4) {
- Image("icon-calendar").font(.system(size: 12)).foregroundColor(Color.zxF04)
- Text("AI 自动排期").font(.system(size: 12)).foregroundColor(Color.zxF04)
- }
- }
- ForEach($studyHomeVM.tasks) { $t in
- routeForTask(t).map { route in
- NavigationLink(value: route) {
- ZXSTaskRowView(task: t) { t.d.toggle() }
- }.foregroundColor(.primary)
- }
- }
- }.padding(.horizontal, 20)
-
- // MARK: - Daily Thinking
- dailyThinkingCard.padding(.horizontal, 20)
-
- Color.clear.frame(height: 100)
+ if let banner = vm.banner {
+ statusBanner(banner).padding(.horizontal, 20)
}
+
+ mainActionCard.padding(.horizontal, 20)
+
+ if vm.todayReviewCount > 0 && !isReviewMainAction {
+ todayReviewSmallCard.padding(.horizontal, 20)
+ }
+
+ if vm.availableQuizCount > 0 && !isSelfTestMainAction {
+ selfTestSmallCard.padding(.horizontal, 20)
+ }
+
+ weeklySummaryCard.padding(.horizontal, 20)
+
+ viewAnalysisLink
+
+ Color.clear.frame(height: 100)
}
- .scrollIndicators(.hidden)
- .zxPullToRefresh { await refreshData() }
}
- .task { await refreshData() }
- .navigationDestination(for: Route.self) { $0.destination }
+ .scrollIndicators(.hidden)
+ .refreshable { await vm.refresh() }
+ }
+
+ // MARK: - Streak Header
+
+ private var streakHeader: some View {
+ HStack {
+ Spacer()
+ HStack(spacing: 4) {
+ Image("icon-flame")
+ .font(.system(size: 14)).foregroundColor(Color.zxOrange)
+ Text("\(vm.streakDays) 天连续")
+ .font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxOrange)
+ }
+ .padding(.horizontal, 12).padding(.vertical, 6)
+ .background(Color.zxOrangeBG(0.1))
+ .clipShape(Capsule())
+ .overlay(Capsule().stroke(Color(hex: "#F97316", opacity: 0.2), lineWidth: 1))
+ }
+ }
+
+ // MARK: - Status Banner
+
+ private func statusBanner(_ text: String) -> some View {
+ HStack(spacing: 8) {
+ Image(systemName: "info.circle")
+ .font(.system(size: 14)).foregroundColor(Color.zxPrimary)
+ Text(text)
+ .font(.system(size: 14)).foregroundColor(Color.zxInkSecondary)
+ }
+ .padding(.horizontal, 16).padding(.vertical, 10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color.zxPrimarySoft)
+ .clipShape(RoundedRectangle(cornerRadius: ZXRadius.md))
}
// MARK: - Main Action Card
- private func mainActionCard(icon: String, title: String, subtitle: String, color: Color, route: Route) -> some View {
+ @ViewBuilder
+ private var mainActionCard: some View {
+ switch vm.mainAction {
+ case .continueSession(let kbTitle, let elapsed):
+ actionCard(
+ title: "继续上次学习",
+ subtitle: "《\(kbTitle)》· \(elapsed)",
+ icon: "arrow.triangle.2.circlepath",
+ color: Color.zxPurple,
+ cta: "继续学习",
+ route: .learningSession(taskTitle: "继续学习", taskType: "study", taskColorHex: "#3D7FFB")
+ )
+ case .todaysReview(let count, let minutes):
+ actionCard(
+ title: "今日复习",
+ subtitle: "\(count) 张卡片待复习 · 约 \(minutes) 分钟",
+ icon: "arrow.triangle.2.circlepath",
+ color: Color.zxPurple,
+ cta: "开始复习",
+ route: .reviewCard
+ )
+ case .selfTest(let quizId, let count):
+ actionCard(
+ title: "资料自测",
+ subtitle: "有 \(count) 个自测题可作答",
+ icon: "list.clipboard",
+ color: Color.zxOrange,
+ cta: "开始自测",
+ route: .quizTake(quizId: quizId)
+ )
+ case .startLearning(let kbId, let count):
+ tabSwitchCard(
+ title: "开始学习",
+ subtitle: "从 \(count) 个知识库中选择内容开始学习",
+ icon: "sparkles",
+ color: Color.zxPrimary,
+ cta: "选择知识库",
+ targetTab: "library"
+ )
+ case .empty:
+ emptyStateCard
+ case .none:
+ EmptyView()
+ }
+ }
+
+ private func actionCard(
+ title: String,
+ subtitle: String,
+ icon: String,
+ color: Color,
+ cta: String,
+ route: Route
+ ) -> some View {
NavigationLink(value: route) {
VStack(spacing: 16) {
- HStack {
+ HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 6) {
- Text("今日主行动").font(.system(size: 11, weight: .semibold)).foregroundColor(Color.zxInkTertiary).textCase(.uppercase).tracking(0.5)
- Text(title).font(.system(size: 24, weight: .heavy)).foregroundColor(Color.zxF0).tracking(-0.5)
- Text(subtitle).font(.system(size: 13)).foregroundColor(Color.zxF04)
+ Text("今日主行动")
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundColor(Color.zxInkTertiary)
+ .textCase(.uppercase)
+ .tracking(0.5)
+ Text(title)
+ .font(.system(size: 24, weight: .heavy))
+ .foregroundColor(Color.zxF0)
+ .tracking(-0.5)
+ Text(subtitle)
+ .font(.system(size: 14))
+ .foregroundColor(Color.zxF04)
}
Spacer()
ZStack {
- Circle().fill(color.opacity(0.12)).frame(width: 64, height: 64)
- Image(systemName: icon).font(.system(size: 26)).foregroundColor(color)
+ Circle()
+ .fill(color.opacity(0.12))
+ .frame(width: 64, height: 64)
+ Image(systemName: icon)
+ .font(.system(size: 26))
+ .foregroundColor(color)
}
}
HStack(spacing: 8) {
- Text("开始").font(.system(size: 14, weight: .semibold))
- Image(systemName: "arrow.right").font(.system(size: 12, weight: .bold))
+ Text(cta)
+ .font(.system(size: 16, weight: .semibold))
+ Image(systemName: "arrow.right")
+ .font(.system(size: 14, weight: .bold))
}
- .foregroundColor(Color.zxOnPrimary).frame(maxWidth: .infinity).frame(height: 44)
- .background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: 12))
+ .foregroundColor(Color.zxOnPrimary)
+ .frame(maxWidth: .infinity).frame(height: 44)
+ .background(ZXGradient.brand)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding(20)
.background(Color.zxSurfaceElevated)
@@ -138,96 +216,251 @@ struct StudyHomeView: View {
.clipShape(RoundedRectangle(cornerRadius: 20))
}
.foregroundColor(.primary)
- .padding(.horizontal, 20)
+ }
+
+ // MARK: - Tab Switch Card
+
+ private func tabSwitchCard(
+ title: String,
+ subtitle: String,
+ icon: String,
+ color: Color,
+ cta: String,
+ targetTab: String
+ ) -> some View {
+ Button {
+ selectedTab = targetTab
+ } label: {
+ VStack(spacing: 16) {
+ HStack(alignment: .top) {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("今日主行动")
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundColor(Color.zxInkTertiary)
+ .textCase(.uppercase)
+ .tracking(0.5)
+ Text(title)
+ .font(.system(size: 24, weight: .heavy))
+ .foregroundColor(Color.zxF0)
+ .tracking(-0.5)
+ Text(subtitle)
+ .font(.system(size: 14))
+ .foregroundColor(Color.zxF04)
+ }
+ Spacer()
+ ZStack {
+ Circle()
+ .fill(color.opacity(0.12))
+ .frame(width: 64, height: 64)
+ Image(systemName: icon)
+ .font(.system(size: 26))
+ .foregroundColor(color)
+ }
+ }
+ HStack(spacing: 8) {
+ Text(cta)
+ .font(.system(size: 16, weight: .semibold))
+ Image(systemName: "arrow.right")
+ .font(.system(size: 14, weight: .bold))
+ }
+ .foregroundColor(Color.zxOnPrimary)
+ .frame(maxWidth: .infinity).frame(height: 44)
+ .background(ZXGradient.brand)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+ .padding(20)
+ .background(Color.zxSurfaceElevated)
+ .overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxHairline, lineWidth: 0.5))
+ .clipShape(RoundedRectangle(cornerRadius: 20))
+ }
+ .foregroundColor(.primary)
+ }
+
+ // MARK: - Empty State
+
+ private var emptyStateCard: some View {
+ VStack(spacing: 16) {
+ HStack(alignment: .top) {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("今日主行动")
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundColor(Color.zxInkTertiary)
+ .textCase(.uppercase)
+ .tracking(0.5)
+ Text("欢迎使用知习")
+ .font(.system(size: 24, weight: .heavy))
+ .foregroundColor(Color.zxF0)
+ .tracking(-0.5)
+ Text("导入学习资料,开启你的学习之旅")
+ .font(.system(size: 14))
+ .foregroundColor(Color.zxF04)
+ }
+ Spacer()
+ ZStack {
+ Circle()
+ .fill(Color.zxPrimary.opacity(0.12))
+ .frame(width: 64, height: 64)
+ Image("icon-brain")
+ .font(.system(size: 26))
+ .foregroundColor(Color.zxPrimary)
+ }
+ }
+ HStack(spacing: 12) {
+ Button {
+ selectedTab = "library"
+ } label: {
+ HStack(spacing: 6) {
+ Image("icon-plus")
+ .resizable().scaledToFit().frame(width: 16, height: 16)
+ Text("创建知识库")
+ .font(.system(size: 16, weight: .semibold))
+ }
+ .foregroundColor(Color.zxOnPrimary)
+ .frame(maxWidth: .infinity).frame(height: 44)
+ .background(ZXGradient.brand)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+ Button {
+ selectedTab = "library"
+ } label: {
+ HStack(spacing: 6) {
+ Image("icon-search")
+ .resizable().scaledToFit().frame(width: 16, height: 16)
+ Text("探索知识库")
+ .font(.system(size: 16, weight: .semibold))
+ }
+ .foregroundColor(Color.zxF0)
+ .frame(maxWidth: .infinity).frame(height: 44)
+ .background(Color.zxFill004)
+ .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.zxBorder008, lineWidth: 1))
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+ }
+ }
+ .padding(20)
+ .background(Color.zxSurfaceElevated)
+ .overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxHairline, lineWidth: 0.5))
+ .clipShape(RoundedRectangle(cornerRadius: 20))
+ }
+
+ // MARK: - Small Cards
+
+ private var todayReviewSmallCard: some View {
+ NavigationLink(value: Route.reviewCard) {
+ HStack(spacing: 14) {
+ ZStack {
+ RoundedRectangle(cornerRadius: ZXRadius.md)
+ .fill(Color.zxPurple.opacity(0.12))
+ .frame(width: 44, height: 44)
+ Image(systemName: "arrow.triangle.2.circlepath")
+ .font(.system(size: 20))
+ .foregroundColor(Color.zxPurple)
+ }
+ VStack(alignment: .leading, spacing: 2) {
+ Text("今日复习")
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundColor(Color.zxF0)
+ Text("\(vm.todayReviewCount) 张卡片 · 约 \(vm.todayReviewEstimatedMinutes) 分钟")
+ .font(.system(size: 14))
+ .foregroundColor(Color.zxF04)
+ }
+ Spacer()
+ Image("icon-chevron-right")
+ .resizable().scaledToFit().frame(width: 16, height: 16)
+ .foregroundColor(Color.zxF03)
+ }
+ .padding(16)
+ .background(Color.zxSurfaceElevated)
+ .overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxHairline, lineWidth: 0.5))
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+ }
+ .foregroundColor(.primary)
+ }
+
+ private var selfTestSmallCard: some View {
+ NavigationLink(value: Route.quizList(knowledgeBaseId: "")) {
+ HStack(spacing: 14) {
+ ZStack {
+ RoundedRectangle(cornerRadius: ZXRadius.md)
+ .fill(Color.zxOrange.opacity(0.12))
+ .frame(width: 44, height: 44)
+ Image(systemName: "list.clipboard")
+ .font(.system(size: 20))
+ .foregroundColor(Color.zxOrange)
+ }
+ VStack(alignment: .leading, spacing: 2) {
+ Text("资料自测")
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundColor(Color.zxF0)
+ Text("\(vm.availableQuizCount) 个自测可用")
+ .font(.system(size: 14))
+ .foregroundColor(Color.zxF04)
+ }
+ Spacer()
+ Image("icon-chevron-right")
+ .resizable().scaledToFit().frame(width: 16, height: 16)
+ .foregroundColor(Color.zxF03)
+ }
+ .padding(16)
+ .background(Color.zxSurfaceElevated)
+ .overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxHairline, lineWidth: 0.5))
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+ }
+ .foregroundColor(.primary)
}
// MARK: - Weekly Summary
private var weeklySummaryCard: some View {
HStack(spacing: 0) {
- weeklyStat("\(weeklyMinutes)", "本周分钟", Color.zxOrange)
- weeklyStat("\(studyHomeVM.doneCount)", "完成任务", Color.zxPurple)
- weeklyStat("\(reviewCount)", "复习卡片", Color.zxTeal)
- weeklyStat("\(streakDays)", "连续天数", Color.zxAmber)
+ weeklyStat("\(vm.weeklyMinutes)", "本周分钟", Color.zxOrange)
+ weeklyStat("\(vm.weeklyCardsReviewed)", "复习卡片", Color.zxPurple)
+ weeklyStat("\(vm.weeklyActiveDays)", "活跃天数", Color.zxTeal)
+ weeklyStat("\(vm.streakDays)", "连续天数", Color.zxAmber)
}
- .padding(.horizontal, 20)
+ .padding(16)
+ .background(Color.zxSurfaceElevated)
+ .overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxHairline, lineWidth: 0.5))
+ .clipShape(RoundedRectangle(cornerRadius: 16))
}
private func weeklyStat(_ value: String, _ label: String, _ color: Color) -> some View {
VStack(spacing: 4) {
- Text(value).font(.system(size: 20, weight: .heavy)).foregroundColor(color)
- Text(label).font(.system(size: 10)).foregroundColor(Color.zxF04)
- }.frame(maxWidth: .infinity)
+ Text(value)
+ .font(.system(size: 20, weight: .heavy))
+ .foregroundColor(color)
+ Text(label)
+ .font(.system(size: 10))
+ .foregroundColor(Color.zxF04)
+ }
+ .frame(maxWidth: .infinity)
}
- // MARK: - Daily Thinking Card
+ // MARK: - View Analysis
- private var dailyThinkingCard: some View {
- VStack(alignment: .leading, spacing: 14) {
- HStack {
- Image("icon-sparkles").font(.system(size: 13)).foregroundColor(Color.zxPrimary)
- .frame(width: 30, height: 30).background(Color.zxPrimarySoft).clipShape(RoundedRectangle(cornerRadius: ZXRadius.sm))
- Text("每日思考题").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxInkPrimary)
- Spacer()
- Text("待回答").font(.system(size: 11, weight: .semibold)).foregroundColor(Color.zxAmberDeep)
- .padding(.horizontal, 8).padding(.vertical, 3).background(Color.zxAmberSoft).clipShape(RoundedRectangle(cornerRadius: ZXRadius.xs))
+ private var viewAnalysisLink: some View {
+ NavigationLink(value: Route.activeRecall) {
+ HStack(spacing: 6) {
+ Text("查看完整分析")
+ .font(.system(size: 14, weight: .medium))
+ .foregroundColor(Color.zxPrimary)
+ Image("icon-chevron-right")
+ .resizable().scaledToFit().frame(width: 14, height: 14)
+ .foregroundColor(Color.zxPrimary)
}
- Text("解释「注意力机制」在 Transformer 中的作用,不能使用搜索,用你自己的话说。")
- .font(.system(size: 15, weight: .regular)).foregroundColor(Color.zxInkPrimary).lineSpacing(6)
- NavigationLink(value: Route.dailyThinking) {
- HStack(spacing: 8) {
- Text("开始回答").font(.system(size: 14, weight: .semibold))
- Image(systemName: "arrow.right").font(.system(size: 12, weight: .semibold))
- }
- .foregroundColor(Color.zxOnPrimary).frame(maxWidth: .infinity).frame(height: 48)
- .background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: ZXRadius.md))
- }.accessibilityLabel("开始回答每日思考题")
}
- .padding(16)
- .background(Color.zxSurfaceElevated)
- .overlay(RoundedRectangle(cornerRadius: ZXRadius.lg).stroke(Color.zxHairline, lineWidth: 0.5))
- .clipShape(RoundedRectangle(cornerRadius: ZXRadius.lg))
+ .padding(.vertical, 8)
}
// MARK: - Helpers
- private func routeForTask(_ t: ZXSTask) -> Route? {
- switch t.tp {
- case "回忆测试": return .activeRecall
- case "费曼练习": return .aiChat
- case "薄弱点": return .weakPoints
- case "间隔复习": return .reviewCard
- case _ where !t.t.isEmpty: return .learningSession(taskTitle: t.t, taskType: t.tp, taskColorHex: t.ch)
- default: return nil
- }
+ private var isReviewMainAction: Bool {
+ if case .todaysReview = vm.mainAction { return true }
+ return false
}
- private func refreshData() async {
- do {
- let streak = try? await ActivityService.shared.streak()
- streakDays = streak?.currentStreak ?? 0
-
- let summary = try? await ActivityService.shared.summary()
- weeklyMinutes = summary?.totalMinutes ?? 0
- reviewCount = summary?.totalCardsReviewed ?? 0
-
- let dueCards = try? await ReviewService.shared.dueCards()
- hasTodayReview = (dueCards?.count ?? 0) > 0
- reviewCount = max(reviewCount, dueCards?.count ?? 0)
- }
+ private var isSelfTestMainAction: Bool {
+ if case .selfTest = vm.mainAction { return true }
+ return false
}
}
-
-struct ZXSTask: Identifiable { let id = UUID(); let t: String; let tp: String; let ch: String; let m: Int; var d: Bool; var c: Color { Color(hex: ch) } }
-struct ZXSTaskRow: View { @Binding var task: ZXSTask
- var body: some View { Button { task.d.toggle() } label: { ZXSTaskRowView(task: task) {} }.foregroundColor(.primary) }
-}
-struct ZXSTaskRowView: View { let task: ZXSTask; var action: () -> Void
- var body: some View { HStack(spacing: 12) { Image(systemName: task.d ? "checkmark.circle.fill" : "circle").font(.system(size: 20)).foregroundColor(task.d ? Color.zxGreen : Color.zxF02)
- 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("icon-play").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() }.zxPressable()
- .accessibilityLabel("\(task.t), \(task.tp), 约\(task.m)分钟")
- .accessibilityAddTraits(task.d ? .isSelected : [])
- .accessibilityHint(task.d ? "已完成" : "双击开始学习") }
-}
diff --git a/AIStudyApp/AIStudyApp/Features/Study/StudyHomeViewModel.swift b/AIStudyApp/AIStudyApp/Features/Study/StudyHomeViewModel.swift
index e47596b..27dde6d 100644
--- a/AIStudyApp/AIStudyApp/Features/Study/StudyHomeViewModel.swift
+++ b/AIStudyApp/AIStudyApp/Features/Study/StudyHomeViewModel.swift
@@ -1,26 +1,198 @@
import Combine
import Foundation
+// MARK: - Action states
+
+enum MainAction: Equatable {
+ case continueSession(kbTitle: String, elapsed: String)
+ case todaysReview(count: Int, estimatedMinutes: Int)
+ case selfTest(quizId: String, count: Int)
+ case startLearning(knowledgeBaseId: String, kbCount: Int)
+ case empty
+}
+
+enum HomeLoadingState: Equatable {
+ case idle
+ case loading
+ case loaded
+ case error(String)
+}
+
+// MARK: - ViewModel
+
@MainActor
final class StudyHomeViewModel: ObservableObject {
- @Published var tasks: [ZXSTask] = [
- ZXSTask(t: "机器学习 - 回忆测试", tp: "回忆测试", ch: "#7C6EFA", m: 10, d: true),
- ZXSTask(t: "高数 - 间隔复习 8 题", tp: "间隔复习", ch: "#F97316", m: 15, d: true),
- ZXSTask(t: "英语词汇 - 25 个待复习", tp: "词汇复习", ch: "#2DD4BF", m: 8, d: false),
- ZXSTask(t: "注意力机制 - 费曼解释", tp: "费曼练习", ch: "#A78BFA", m: 12, d: false),
- ZXSTask(t: "产品设计 - 薄弱点复习", tp: "薄弱点", ch: "#F59E0B", m: 10, d: false),
- ]
+ @Published var loadingState: HomeLoadingState = .idle
+ @Published var mainAction: MainAction?
+ @Published var todayReviewCount = 0
+ @Published var todayReviewEstimatedMinutes = 0
+ @Published var availableQuizCount = 0
+ @Published var weeklyMinutes = 0
+ @Published var weeklyCardsReviewed = 0
+ @Published var weeklyActiveDays = 0
+ @Published var streakDays = 0
+ @Published var banner: String?
- @Published var weekActivity: [CGFloat] = [0.3, 0.7, 1.0, 0.4, 0.9, 0.6, 0.2]
- let dayLabels = ["一", "二", "三", "四", "五", "六", "日"]
+ private var firstQuizId: String?
+ private var firstKbId: String?
- var doneCount: Int { tasks.filter(\.d).count }
- var progress: Double { tasks.isEmpty ? 0 : Double(doneCount) / Double(tasks.count) }
- var doneMinutes: Int { tasks.filter(\.d).map(\.m).reduce(0, +) }
- var remainingMinutes: Int { tasks.filter { !$0.d }.map(\.m).reduce(0, +) }
+ func loadAll() async {
+ loadingState = .loading
+ banner = nil
- func toggleTask(_ task: ZXSTask) {
- guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
- tasks[idx].d.toggle()
+ async let sessions = try? LearningSessionService.shared.list()
+ async let dueCards = try? ReviewService.shared.dueCards()
+ async let quizzes = try? QuizService.shared.list()
+ async let knowledgeBases = try? KnowledgeBaseService.shared.list()
+ async let summary = try? ActivityService.shared.summary()
+ async let streak = try? ActivityService.shared.streak()
+
+ let (sRes, dRes, qRes, kRes, sumRes, stRes) = await (sessions, dueCards, quizzes, knowledgeBases, summary, streak)
+
+ let sessionsResult = sRes ?? []
+ let dueCardsResult = dRes ?? []
+ let quizzesResult = qRes ?? []
+ let kbResult = kRes ?? []
+
+ // Store first IDs for navigation
+ firstQuizId = quizzesResult.first?.id
+ firstKbId = kbResult.first?.id
+
+ // Evaluate main action
+ mainAction = evaluatePriority(
+ sessions: sessionsResult,
+ dueCards: dueCardsResult,
+ quizzes: quizzesResult,
+ knowledgeBases: kbResult
+ )
+
+ // Stats
+ todayReviewCount = dueCardsResult.count
+ todayReviewEstimatedMinutes = max(1, dueCardsResult.count)
+ availableQuizCount = quizzesResult.count
+ weeklyMinutes = sumRes?.totalMinutes ?? 0
+ weeklyCardsReviewed = sumRes?.totalCardsReviewed ?? 0
+ weeklyActiveDays = sumRes?.activeDays ?? 0
+ streakDays = stRes?.currentStreak ?? 0
+
+ // Banner
+ banner = computeBanner(
+ sessions: sessionsResult,
+ quizzes: quizzesResult,
+ dueCards: dueCardsResult
+ )
+
+ loadingState = .loaded
+ }
+
+ func refresh() async {
+ async let sessions = try? LearningSessionService.shared.list()
+ async let dueCards = try? ReviewService.shared.dueCards()
+ async let quizzes = try? QuizService.shared.list()
+ async let knowledgeBases = try? KnowledgeBaseService.shared.list()
+ async let summary = try? ActivityService.shared.summary()
+ async let streak = try? ActivityService.shared.streak()
+
+ let (sRes, dRes, qRes, kRes, sumRes, stRes) = await (sessions, dueCards, quizzes, knowledgeBases, summary, streak)
+
+ let sessionsResult = sRes ?? []
+ let dueCardsResult = dRes ?? []
+ let quizzesResult = qRes ?? []
+ let kbResult = kRes ?? []
+
+ firstQuizId = quizzesResult.first?.id
+ firstKbId = kbResult.first?.id
+
+ mainAction = evaluatePriority(
+ sessions: sessionsResult,
+ dueCards: dueCardsResult,
+ quizzes: quizzesResult,
+ knowledgeBases: kbResult
+ )
+
+ todayReviewCount = dueCardsResult.count
+ todayReviewEstimatedMinutes = max(1, dueCardsResult.count)
+ availableQuizCount = quizzesResult.count
+ weeklyMinutes = sumRes?.totalMinutes ?? 0
+ weeklyCardsReviewed = sumRes?.totalCardsReviewed ?? 0
+ weeklyActiveDays = sumRes?.activeDays ?? 0
+ streakDays = stRes?.currentStreak ?? 0
+ }
+
+ // MARK: - Priority Logic
+
+ private func evaluatePriority(
+ sessions: [LearningSession],
+ dueCards: [ReviewCard],
+ quizzes: [Quiz],
+ knowledgeBases: [KnowledgeBase]
+ ) -> MainAction {
+ // Priority 1: Unfinished session
+ if let unfinished = sessions
+ .filter({ $0.status != nil && $0.status != "completed" })
+ .sorted(by: { ($0.startedAt ?? "") > ($1.startedAt ?? "") })
+ .first {
+ let kbTitle = knowledgeBases.first(where: { $0.id == unfinished.knowledgeBaseId })?.title ?? "学习"
+ let elapsed = formatElapsedSince(unfinished.startedAt)
+ return .continueSession(kbTitle: kbTitle, elapsed: elapsed)
+ }
+
+ // Priority 2: Today's review
+ if !dueCards.isEmpty {
+ return .todaysReview(count: dueCards.count, estimatedMinutes: max(1, dueCards.count))
+ }
+
+ // Priority 3: Self-test
+ if let quizId = firstQuizId, !quizzes.isEmpty {
+ return .selfTest(quizId: quizId, count: quizzes.count)
+ }
+
+ // Priority 4: Start learning
+ if let kbId = firstKbId, !knowledgeBases.isEmpty {
+ return .startLearning(knowledgeBaseId: kbId, kbCount: knowledgeBases.count)
+ }
+
+ // Priority 5: Empty
+ return .empty
+ }
+
+ // MARK: - Banner
+
+ private func computeBanner(
+ sessions: [LearningSession],
+ quizzes: [Quiz],
+ dueCards: [ReviewCard]
+ ) -> String? {
+ // No banner needed if nothing is happening
+ if dueCards.isEmpty && quizzes.isEmpty && sessions.allSatisfy({ $0.status == "completed" }) {
+ return nil
+ }
+ return nil
+ }
+
+ // MARK: - Helpers
+
+ private func formatElapsedSince(_ iso: String?) -> String {
+ guard let iso else { return "最近" }
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ guard let date = formatter.date(from: iso) else {
+ // Try without fractional seconds
+ formatter.formatOptions = [.withInternetDateTime]
+ guard let date2 = formatter.date(from: iso) else { return "最近" }
+ return relativeTime(from: date2)
+ }
+ return relativeTime(from: date)
+ }
+
+ private func relativeTime(from date: Date) -> String {
+ let interval = Date().timeIntervalSince(date)
+ let minutes = Int(interval / 60)
+ if minutes < 1 { return "刚刚" }
+ if minutes < 60 { return "\(minutes) 分钟前" }
+ let hours = minutes / 60
+ if hours < 24 { return "\(hours) 小时前" }
+ let days = hours / 24
+ return "\(days) 天前"
}
}