From 4ebb70c036c0be01e2a0ed6b4665d93e62613098 Mon Sep 17 00:00:00 2001 From: wangdl Date: Sat, 30 May 2026 20:07:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9B=BE=E6=A0=87=E7=BA=BF=E5=9E=8B?= =?UTF-8?q?=E5=8C=96=20+=20=E9=A6=96=E9=A1=B5=E9=87=8D=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=20+=20=E7=9F=A5=E8=AF=86=E5=BA=93=E5=8D=A1=E7=89=87=E4=BC=98?= =?UTF-8?q?=E5=8C=96=20+=20=E7=9F=A5=E8=AF=86=E7=82=B9=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 所有 SF Symbol .fill 图标替换为线性版本 - 自定义加载动画全部替换为原生 ProgressView/refreshable - StudyHomeView 重设计:优先级驱动主行动卡片 - ZLibraryCard 重新设计:封面图自适应、信息布局优化 - LibraryDetailPage:顶部KB信息区、···菜单、排序、长按操作 - 知识点列表:文件类型图标、学习时长、分割线样式 - 弥散渐变顶部背景 - 新增 icon-folder、icon-xmark SVG Co-Authored-By: Claude Opus 4.7 --- .gitignore | 56 -- .../UserInterfaceState.xcuserstate | Bin 0 -> 38265 bytes .../xcschemes/xcschememanagement.plist | 14 + AIStudyApp/AIStudyApp/AIStudyAppApp.swift | 24 +- .../Icons/icon-folder.imageset/Contents.json | 1 + .../icon-folder.imageset/icon-folder.svg | 19 + .../Icons/icon-xmark.imageset/Contents.json | 1 + .../Icons/icon-xmark.imageset/icon-xmark.svg | 20 + AIStudyApp/AIStudyApp/ContentView.swift | 50 +- .../Core/DesignSystem/ZXAnimations.swift | 8 +- .../Core/DesignSystem/ZXLoadingView.swift | 2 +- .../ZXRefreshableScrollView.swift | 12 +- .../Core/DesignSystem/ZXToast.swift | 6 +- .../AIStudyApp/Core/Navigation/Route.swift | 2 +- .../AIStudyApp/Features/AI/AIChatPage.swift | 16 +- .../Features/AI/AIFeedbackPageView.swift | 14 +- .../AIStudyApp/Features/AI/AIHomeView.swift | 20 +- .../Features/AI/ActiveRecallView.swift | 8 +- .../Features/AI/DailyThinkingPage.swift | 2 +- .../Features/AI/RecallTestPage.swift | 2 +- .../Features/AI/WeakPointsPage.swift | 2 +- .../Features/Analysis/ActivityViewModel.swift | 60 +- .../Features/Analysis/AnalysisHomeView.swift | 46 +- .../Features/Library/LibraryHomeView.swift | 112 +++- .../Features/Library/LibrarySubpages.swift | 419 ++++++++++--- .../Features/Library/LibraryViewModel.swift | 6 +- .../Features/Profile/EditProfilePage.swift | 6 +- .../Features/Profile/FeedbackFormView.swift | 2 +- .../Profile/GoalSettingDetailView.swift | 4 +- .../Profile/MethodPreferenceView.swift | 6 +- .../Profile/NotificationListView.swift | 14 +- .../Features/Profile/ProfileView.swift | 12 +- .../Features/Profile/SettingsView.swift | 4 +- .../AIStudyApp/Features/Quiz/QuizViews.swift | 32 +- .../Features/Study/LearningSessionView.swift | 20 +- .../Features/Study/ReviewCardView.swift | 6 +- .../Features/Study/StudyHomeView.swift | 583 ++++++++++++------ .../Features/Study/StudyHomeViewModel.swift | 204 +++++- 38 files changed, 1278 insertions(+), 537 deletions(-) create mode 100644 AIStudyApp/AIStudyApp.xcodeproj/project.xcworkspace/xcuserdata/Admin1.xcuserdatad/UserInterfaceState.xcuserstate create mode 100644 AIStudyApp/AIStudyApp.xcodeproj/xcuserdata/Admin1.xcuserdatad/xcschemes/xcschememanagement.plist create mode 100644 AIStudyApp/AIStudyApp/Assets.xcassets/Icons/icon-folder.imageset/Contents.json create mode 100644 AIStudyApp/AIStudyApp/Assets.xcassets/Icons/icon-folder.imageset/icon-folder.svg create mode 100644 AIStudyApp/AIStudyApp/Assets.xcassets/Icons/icon-xmark.imageset/Contents.json create mode 100644 AIStudyApp/AIStudyApp/Assets.xcassets/Icons/icon-xmark.imageset/icon-xmark.svg 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 0000000000000000000000000000000000000000..6f902bb7cbd0b05d10c937bf7c00fdff5820c3f2 GIT binary patch literal 38265 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 C)# + + + + 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) 天前" } }