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) 天前" } }