- 新增 AuthManager (ObservableObject) 集中管理鉴权状态: - session 恢复 → token 验证 → 自动刷新 - 登出自动重定向到登录页 - NotificationCenter 监听 401 实现全局踢回 - APIClient 新增 401 自动 refresh + 单次重试 - App.swift 重构鉴权门控: - 去掉 hasCompletedOnboarding 绕过鉴权漏洞 - 拆分为 SplashScreen / PreLoginFlow / PostLoginOnboardingFlow / ContentView - LoginPage 移除"跳过"按钮 - KeychainHelper 实现 token 安全存储 - APIModels 对齐后端 Prisma schema (UserProfile/KnowledgeBase/ReviewCard 等) - APIService 简化 AuthService,token 管理迁移至 AuthManager - 新增 8 个 ViewModel 接入 API: ProfileViewModel, LibraryViewModel, StudyViewModel, ActiveRecallViewModel, AIAnalysisViewModel, ReviewViewModel, ActivityViewModel - 新增 EditProfilePage 编辑资料页 - 新增 NotificationListView 通知列表页 - AIHomeView 修复"检测中"卡住 (改用公开 GET / 健康检查) - SettingsView 登出调用 AuthManager.signOut() 实现重定向 - 修复 NotificationItem 命名冲突、Combine import 缺失等编译错误 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
310 lines
18 KiB
Swift
310 lines
18 KiB
Swift
import SwiftUI
|
|
|
|
struct SettingsView: View {
|
|
@EnvironmentObject var authManager: AuthManager
|
|
@StateObject private var profileVM = ProfileViewModel()
|
|
@State private var language = "zh-CN"
|
|
@State private var appearance = "system"
|
|
@State private var defaultFocusMinutes = 25
|
|
@State private var notificationEnabled = true
|
|
@State private var reviewReminder = true
|
|
@State private var reminderTime = "20:00"
|
|
@State private var intervalDays = "1"
|
|
@State private var iCloudSync = false
|
|
@State private var autoBackup = false
|
|
@State private var showLogoutAlert = false
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color.zxBg0.ignoresSafeArea()
|
|
ScrollView {
|
|
let _ = Task { await profileVM.loadProfile(); if let p = profileVM.preferences {
|
|
appearance = p.appearance ?? "system"
|
|
language = p.language ?? "zh-CN"
|
|
defaultFocusMinutes = p.defaultFocusMinutes ?? 25
|
|
notificationEnabled = p.notificationEnabled ?? true
|
|
reviewReminder = notificationEnabled
|
|
} }
|
|
VStack(spacing: 16) {
|
|
sectionHeader("外观与语言")
|
|
VStack(spacing: 0) {
|
|
ZXSettingRow(title: "外观", value: appearanceLabel, icon: "moon.stars.fill", color: Color.zxPurple)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { cycleAppearance() }
|
|
ZXSettingDivider()
|
|
ZXSettingRow(title: "语言", value: language == "zh-CN" ? "简体中文" : "English", icon: "globe", color: Color.zxTeal)
|
|
}
|
|
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
|
|
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
|
|
|
|
sectionHeader("学习设置")
|
|
VStack(spacing: 0) {
|
|
NavigationLink(destination: GoalSettingDetailView()) {
|
|
ZXSettingRow(title: "学习目标", value: "备考考试", icon: "target", color: Color.zxOrange)
|
|
}.foregroundColor(.primary)
|
|
ZXSettingDivider()
|
|
NavigationLink(destination: MethodPreferenceView()) {
|
|
ZXSettingRow(title: "学习方法偏好", value: "间隔回忆 · 费曼技巧", icon: "brain.head.profile", color: Color.zxPurple)
|
|
}.foregroundColor(.primary)
|
|
}
|
|
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
|
|
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
|
|
|
|
sectionHeader("复习提醒")
|
|
VStack(spacing: 0) {
|
|
ZXSettingToggleRow(title: "开启复习提醒", icon: "bell.badge.fill", color: Color.zxOrange, isOn: $reviewReminder)
|
|
if reviewReminder {
|
|
ZXSettingDivider()
|
|
ZXSettingPickerRow(title: "提醒时间", value: $reminderTime, options: ["08:00", "12:00", "18:00", "20:00", "21:00"])
|
|
ZXSettingDivider()
|
|
ZXSettingPickerRow(title: "间隔天数", value: $intervalDays, options: ["1", "2", "3", "5", "7"])
|
|
}
|
|
}
|
|
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
|
|
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
|
|
|
|
sectionHeader("数据")
|
|
VStack(spacing: 0) {
|
|
ZXSettingToggleRow(title: "iCloud 同步", icon: "icloud.fill", color: Color.zxTeal, isOn: $iCloudSync)
|
|
ZXSettingDivider()
|
|
ZXSettingToggleRow(title: "自动备份", icon: "arrow.triangle.2.circlepath", color: Color.zxAccent, isOn: $autoBackup)
|
|
}
|
|
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
|
|
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
|
|
|
|
VStack(spacing: 0) {
|
|
NavigationLink(destination: FeedbackFormView()) {
|
|
ZXSettingRow(title: "帮助与反馈", value: "", icon: "questionmark.circle.fill", color: Color.zxAccent)
|
|
}.foregroundColor(.primary)
|
|
ZXSettingDivider()
|
|
ZXSettingRow(title: "隐私政策", value: "", icon: "hand.raised.fill", color: Color.zxYellow)
|
|
ZXSettingDivider()
|
|
ZXSettingRow(title: "用户协议", value: "", icon: "doc.text.fill", color: Color.zxGreen)
|
|
}
|
|
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
|
|
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
|
|
|
|
VStack(spacing: 0) {
|
|
Button {
|
|
showLogoutAlert = true
|
|
} label: {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "rectangle.portrait.and.arrow.right").font(.system(size: 16)).foregroundColor(.red).frame(width: 32, height: 32).background(Color.red.opacity(0.12)).clipShape(RoundedRectangle(cornerRadius: 8))
|
|
Text("退出登录").font(.system(size: 14, weight: .semibold)).foregroundColor(.red)
|
|
Spacer()
|
|
}.padding(.horizontal, 16).padding(.vertical, 14)
|
|
}
|
|
}
|
|
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
|
|
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
|
|
.alert("退出登录", isPresented: $showLogoutAlert) {
|
|
Button("取消", role: .cancel) {}
|
|
Button("退出", role: .destructive) {
|
|
Task { await authManager.signOut() }
|
|
}
|
|
} message: {
|
|
Text("退出后需要重新登录")
|
|
}
|
|
|
|
HStack(spacing: 4) {
|
|
Text("知习 v1.0").font(.system(size: 12)).foregroundColor(Color.zxF03)
|
|
}.padding(.bottom, 100)
|
|
}.padding(.horizontal, 20).padding(.top, 8)
|
|
}
|
|
.scrollIndicators(.hidden)
|
|
}
|
|
.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)
|
|
}
|
|
|
|
private func sectionHeader(_ text: String) -> some View {
|
|
Text(text).font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5).padding(.top, 4)
|
|
}
|
|
|
|
private var appearanceLabel: String {
|
|
switch appearance { case "system": return "跟随系统"; case "dark": return "深色模式"; default: return "浅色模式" }
|
|
}
|
|
|
|
private func cycleAppearance() {
|
|
switch appearance {
|
|
case "system": appearance = "dark"
|
|
case "dark": appearance = "light"
|
|
default: appearance = "system"
|
|
}
|
|
Task {
|
|
await profileVM.updatePreferences(UpdatePreferencesRequest(
|
|
preferredMethods: nil, defaultFocusMinutes: defaultFocusMinutes,
|
|
aiSuggestionLevel: nil, language: language, appearance: appearance,
|
|
notificationEnabled: notificationEnabled
|
|
))
|
|
}
|
|
}
|
|
|
|
private func saveNotificationSettings() {
|
|
Task {
|
|
await profileVM.updatePreferences(UpdatePreferencesRequest(
|
|
preferredMethods: nil, defaultFocusMinutes: defaultFocusMinutes,
|
|
aiSuggestionLevel: nil, language: language, appearance: appearance,
|
|
notificationEnabled: notificationEnabled
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
struct GoalSettingDetailView: View {
|
|
@State private var selectedGoal = "备考考试"
|
|
let goals = [("🧑🎓","备考考试","公考、考研、考证等"),("💼","职业技能","编程、设计、产品等"),("📚","通识学习","扩充知识面"),("🎯","自定义","设定自己的目标")]
|
|
@State private var dailyMins = "30 分钟"
|
|
let times = ["15 分钟","30 分钟","1 小时","不限制"]
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color.zxBg0.ignoresSafeArea()
|
|
ScrollView {
|
|
VStack(spacing: 16) {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("学习目标").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035)
|
|
ForEach(goals, id: \.1) { g in let sel = selectedGoal == g.1
|
|
Button { selectedGoal = g.1 } label: {
|
|
HStack(spacing: 12) {
|
|
Text(g.0).font(.system(size: 22)).frame(width: 44, height: 44).background(sel ? Color(hex: "#7C6EFA", opacity: 0.15) : Color.zxFill005).clipShape(RoundedRectangle(cornerRadius: 12))
|
|
VStack(alignment: .leading, spacing: 2) { Text(g.1).font(.system(size: 15, weight: .semibold)).foregroundColor(sel ? Color.zxPurple : Color.zxF0); Text(g.2).font(.system(size: 12)).foregroundColor(Color.zxF04) }
|
|
Spacer()
|
|
Circle().stroke(sel ? Color.zxPurple : Color(hex: "#FFFFFF", opacity: 0.2), lineWidth: 2).frame(width: 22, height: 22).overlay { if sel { Circle().fill(Color.zxPurple).frame(width: 12, height: 12) } }
|
|
}.padding(14).background(sel ? Color(hex: "#7C6EFA", opacity: 0.08) : Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 16))
|
|
}.foregroundColor(.primary)
|
|
}
|
|
}
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("每日学习时间").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035)
|
|
HStack(spacing: 8) { ForEach(times, id: \.self) { t in let sel = dailyMins == t; Button { dailyMins = t } label: { Text(t).font(.system(size: 12)).fontWeight(sel ? .semibold : .regular).foregroundColor(sel ? Color.zxPurple : Color.zxF05).frame(maxWidth: .infinity).frame(height: 40).background(sel ? Color(hex: "#7C6EFA", opacity: 0.1) : Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 12)) }.foregroundColor(.primary) } }
|
|
}
|
|
Button {
|
|
Task {
|
|
_ = try? await UserService.shared.updateProfileDetail(UpdateProfileDataRequest(
|
|
learningIdentity: nil, learningDirection: nil, bio: nil, currentGoal: selectedGoal
|
|
))
|
|
}
|
|
} label: {
|
|
Text("保存").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16))
|
|
}
|
|
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80)
|
|
}.scrollIndicators(.hidden)
|
|
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)
|
|
}
|
|
}
|
|
|
|
struct MethodPreferenceView: View {
|
|
@State private var methods: Set<String> = ["间隔回忆", "费曼技巧"]
|
|
let allMethods = ["间隔回忆", "费曼技巧", "AI 分析", "主动回忆"]
|
|
@State private var saved = false
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color.zxBg0.ignoresSafeArea()
|
|
ScrollView {
|
|
VStack(spacing: 16) {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("选择你偏好的学习方法").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035)
|
|
ForEach(allMethods, id: \.self) { m in let sel = methods.contains(m)
|
|
Button { if sel { methods.remove(m) } else { methods.insert(m) } } label: {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: sel ? "checkmark.circle.fill" : "circle").font(.system(size: 20)).foregroundColor(sel ? Color.zxPurple : Color.zxF02)
|
|
Text(m).font(.system(size: 15, weight: .semibold)).foregroundColor(Color.zxF0)
|
|
Spacer()
|
|
}.padding(14).background(sel ? Color(hex: "#7C6EFA", opacity: 0.08) : Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 14))
|
|
}.foregroundColor(.primary)
|
|
}
|
|
}
|
|
Button {
|
|
Task {
|
|
_ = try? await UserService.shared.updatePreferences(UpdatePreferencesRequest(
|
|
preferredMethods: Array(methods), defaultFocusMinutes: nil,
|
|
aiSuggestionLevel: nil, language: nil, appearance: nil,
|
|
notificationEnabled: nil
|
|
))
|
|
saved = true
|
|
}
|
|
} label: {
|
|
Text(saved ? "已保存" : "保存").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16))
|
|
}
|
|
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80)
|
|
}.scrollIndicators(.hidden)
|
|
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)
|
|
}
|
|
}
|
|
|
|
struct FeedbackFormView: View {
|
|
@State private var type = "功能建议"
|
|
@State private var content = ""
|
|
@State private var submitted = false
|
|
let types = ["Bug 反馈", "功能建议", "内容问题", "其他"]
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color.zxBg0.ignoresSafeArea()
|
|
ScrollView {
|
|
VStack(spacing: 16) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("反馈类型").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035)
|
|
HStack(spacing: 8) { ForEach(types, id: \.self) { t in let sel = type == t; Button { type = t } label: { Text(t).font(.system(size: 12)).foregroundColor(sel ? .white : Color.zxF05).padding(.horizontal, 12).padding(.vertical, 6).background(sel ? AnyView(ZXGradient.brandPurple) : AnyView(Color.zxFill005)).clipShape(Capsule()) } } }
|
|
}
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("内容").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035)
|
|
TextEditor(text: $content).frame(minHeight: 150).scrollContentBackground(.hidden).padding(12).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
|
|
}
|
|
Button {
|
|
Task {
|
|
_ = try? await FeedbackService.shared.submit(category: type, content: content)
|
|
submitted = true
|
|
}
|
|
} label: {
|
|
Text(submitted ? "已提交" : "提交").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16))
|
|
}
|
|
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80)
|
|
}.scrollIndicators(.hidden)
|
|
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar)
|
|
}
|
|
}
|
|
|
|
struct ZXSettingRow: View {
|
|
let title: String; let value: String; let icon: String; let color: Color
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: icon).font(.system(size: 16)).foregroundColor(color).frame(width: 32, height: 32).background(color.opacity(0.12)).clipShape(RoundedRectangle(cornerRadius: 8))
|
|
Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0)
|
|
Spacer()
|
|
if !value.isEmpty { Text(value).font(.system(size: 13)).foregroundColor(Color.zxF03) }
|
|
Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(Color.zxF03)
|
|
}.padding(.horizontal, 16).padding(.vertical, 14)
|
|
}
|
|
}
|
|
|
|
struct ZXSettingToggleRow: View {
|
|
let title: String; let icon: String; let color: Color; @Binding var isOn: Bool
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: icon).font(.system(size: 16)).foregroundColor(color).frame(width: 32, height: 32).background(color.opacity(0.12)).clipShape(RoundedRectangle(cornerRadius: 8))
|
|
Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0)
|
|
Spacer()
|
|
Toggle("", isOn: $isOn).labelsHidden().tint(Color.zxPurple)
|
|
}.padding(.horizontal, 16).padding(.vertical, 14)
|
|
}
|
|
}
|
|
|
|
struct ZXSettingPickerRow: View {
|
|
let title: String; @Binding var value: String; let options: [String]
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0).opacity(0.6)
|
|
Spacer()
|
|
Picker(title, selection: $value) {
|
|
ForEach(options, id: \.self) { o in Text(o).tag(o) }
|
|
}.pickerStyle(.segmented).frame(width: 200).tint(Color.zxPurple)
|
|
}.padding(.horizontal, 16).padding(.vertical, 10)
|
|
}
|
|
}
|
|
|
|
struct ZXSettingDivider: View {
|
|
var body: some View { Rectangle().fill(Color.zxBorder008).frame(height: 1).padding(.leading, 60) }
|
|
}
|