feat: 图标线型化 + 首页重设计 + 知识库卡片优化 + 知识点列表重构

- 所有 SF Symbol .fill 图标替换为线性版本
- 自定义加载动画全部替换为原生 ProgressView/refreshable
- StudyHomeView 重设计:优先级驱动主行动卡片
- ZLibraryCard 重新设计:封面图自适应、信息布局优化
- LibraryDetailPage:顶部KB信息区、···菜单、排序、长按操作
- 知识点列表:文件类型图标、学习时长、分割线样式
- 弥散渐变顶部背景
- 新增 icon-folder、icon-xmark SVG

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-05-30 20:07:15 +08:00
parent 52756d3bb0
commit 4ebb70c036
38 changed files with 1278 additions and 537 deletions

56
.gitignore vendored
View File

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

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>AIStudyApp.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

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

View File

@ -0,0 +1 @@
{"images":[{"filename":"icon-folder.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"template-rendering-intent":"template","preserves-vector-representation":true}}

View File

@ -0,0 +1,19 @@
<!--
tags: [cancel, "no", directory, dir, folder, collection, container, group, file, paper]
category: Document
version: "1.0"
unicode: "eaad"
-->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2" />
</svg>

After

Width:  |  Height:  |  Size: 453 B

View File

@ -0,0 +1 @@
{"images":[{"filename":"icon-xmark.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"template-rendering-intent":"template","preserves-vector-representation":true}}

View File

@ -0,0 +1,20 @@
<!--
category: System
tags: [cancel, remove, delete, empty, close, x]
version: "1.0"
unicode: "eb55"
-->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 6l-12 12" />
<path d="M6 6l12 12" />
</svg>

After

Width:  |  Height:  |  Size: 367 B

View File

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

View File

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

View File

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

View File

@ -27,7 +27,7 @@ struct ZXRefreshableScrollView<Content: View>: 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -32,7 +32,7 @@ struct RecallTestPage: View {
}
.scrollIndicators(.hidden)
}
.navigationBarTitleDisplayMode(.inline).hideTabBarWithAnimation()
.navigationBarTitleDisplayMode(.inline).toolbar(.hidden, for: .tabBar)
.toolbarBackground(.hidden, for: .navigationBar)
}
}

View File

@ -16,7 +16,7 @@ struct WeakPointsPage: View {
}
.scrollIndicators(.hidden)
}
.navigationBarTitleDisplayMode(.inline).hideTabBarWithAnimation()
.navigationBarTitleDisplayMode(.inline).toolbar(.hidden, for: .tabBar)
.toolbarBackground(.hidden, for: .navigationBar)
}
}

View File

@ -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 ?? []
}
}

View File

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

View File

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

View File

@ -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<String> = []
@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)
}
}

View File

@ -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 = "加载知识点失败" }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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