WangDL 5e19bd740e feat(ios): 鉴权系统完善 + 前后端打通 + 模型对齐 + ViewModel 创建
- 新增 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>
2026-05-17 19:06:23 +08:00

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