feat(ios): AppSession 状态枚举接入路由 + TabBar 精简 + authorizationCode

- AuthManager: 移除 isAuthenticated/isRestoring,仅保留 AppSession 枚举
- App 入口: switch session 分发行路由,支持 disabled/deleted 状态页
- 新增 AccountStatusView(禁用/注销提示)
- ContentView: TabBar 5→4(学习/知识库/分析/我的),去掉 AI 首页
- StudyHomeView: 融合每日思考题,去掉 AI 输入栏和本周活跃
- AnalysisHomeView: 新增本周学习活跃柱状图
- AppleAuthRequest 新增 authorizationCode,LoginPage 提取传递
- APIError 支持 errorCode,APIClient 解析服务端 errorCode
- AuthManager.applyErrorCode() 根据错误码切换状态

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-05-27 21:08:11 +08:00
parent 0f8e542b2a
commit 539b9a7d2b
9 changed files with 465 additions and 123 deletions

View File

@ -18,9 +18,11 @@ struct AIStudyAppApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
Group { Group {
if authManager.isRestoring { switch authManager.session {
case .unknown:
SplashScreen() SplashScreen()
} else if authManager.isAuthenticated {
case .authenticated:
if hasCompletedOnboarding { if hasCompletedOnboarding {
ContentView() ContentView()
.environmentObject(authManager) .environmentObject(authManager)
@ -28,9 +30,29 @@ struct AIStudyAppApp: App {
PostLoginOnboardingFlow(hasCompletedOnboarding: $hasCompletedOnboarding) PostLoginOnboardingFlow(hasCompletedOnboarding: $hasCompletedOnboarding)
.environmentObject(authManager) .environmentObject(authManager)
} }
} else {
case .disabled:
AccountStatusView(
icon: "person.crop.circle.badge.xmark",
title: "账号已被禁用",
message: "您的账号已被管理员禁用,如有疑问请联系客服。",
onBackToLogin: { authManager.session = .unauthenticated }
)
case .deleted:
AccountStatusView(
icon: "person.crop.circle.badge.minus",
title: "账号已注销",
message: "您的账号已成功注销,欢迎随时回来重新注册。",
onBackToLogin: { authManager.session = .unauthenticated }
)
case .expired, .unauthenticated:
PreLoginFlow() PreLoginFlow()
.environmentObject(authManager) .environmentObject(authManager)
case .authenticating, .refreshing:
SplashScreen()
} }
} }
.preferredColorScheme(effectiveColorScheme) .preferredColorScheme(effectiveColorScheme)
@ -253,6 +275,7 @@ struct LoginPage: View {
} }
let givenName = credential.fullName?.givenName let givenName = credential.fullName?.givenName
let familyName = credential.fullName?.familyName let familyName = credential.fullName?.familyName
let authCode = credential.authorizationCode.flatMap { String(data: $0, encoding: .utf8) }
isLoggingIn = true isLoggingIn = true
errorMessage = nil errorMessage = nil
@ -264,6 +287,7 @@ struct LoginPage: View {
LoginPage.currentRawNonce = nil // LoginPage.currentRawNonce = nil //
let resp = try await AuthService.shared.appleLogin( let resp = try await AuthService.shared.appleLogin(
identityToken: tokenStr, identityToken: tokenStr,
authorizationCode: authCode,
givenName: givenName, givenName: givenName,
familyName: familyName, familyName: familyName,
nonce: rawNonce nonce: rawNonce
@ -325,6 +349,50 @@ struct OnboardingPage: View {
} }
} }
// MARK: - Account Status (disabled / deleted)
struct AccountStatusView: View {
let icon: String
let title: String
let message: String
let onBackToLogin: () -> Void
var body: some View {
ZStack {
ZXGradient.page.ignoresSafeArea()
VStack(spacing: 24) {
Image(systemName: icon)
.font(.system(size: 56))
.foregroundColor(Color.zxCoral.opacity(0.6))
.padding(.bottom, 8)
Text(title)
.font(.system(size: 22, weight: .heavy))
.foregroundColor(Color.zxF0)
Text(message)
.font(.system(size: 15))
.foregroundColor(Color.zxF04)
.multilineTextAlignment(.center)
.lineSpacing(4)
.padding(.horizontal, 40)
Button {
onBackToLogin()
} label: {
Text("返回首页")
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Color.zxOnPrimary)
.frame(width: 200, height: 48)
.background(ZXGradient.brand)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.padding(.top, 12)
}
}
}
}
// MARK: - GoalSetup // MARK: - GoalSetup
struct GoalSetupPage: View { struct GoalSetupPage: View {

View File

@ -1,72 +1,51 @@
import SwiftUI import SwiftUI
struct ContentView: View { struct ContentView: View {
@State private var selectedTab = "ai" @State private var selectedTab = "study"
var body: some View { var body: some View {
ZStack { TabView(selection: $selectedTab) {
switch selectedTab { NavigationStack {
case "ai": NavigationStack { AIHomeView().background(Color.zxBg0.ignoresSafeArea()) } StudyHomeView()
case "library": NavigationStack { LibraryHomeView().background(Color.zxBg0.ignoresSafeArea()) } .background(Color.zxCanvas.ignoresSafeArea())
case "study": NavigationStack { StudyHomeView().background(Color.zxBg0.ignoresSafeArea()) }
case "analysis": NavigationStack { AnalysisHomeView().background(Color.zxBg0.ignoresSafeArea()) }
case "profile": NavigationStack { ProfileView().background(Color.zxBg0.ignoresSafeArea()) }
default: NavigationStack { AIHomeView() }
} }
VStack { Spacer(); ZXTabBar(active: $selectedTab) }.ignoresSafeArea(edges: .bottom) .tabItem {
Label("学习", systemImage: "flame")
}
.tag("study")
NavigationStack {
LibraryHomeView()
.background(Color.zxCanvas.ignoresSafeArea())
}
.tabItem {
Label("知识库", systemImage: "books.vertical")
}
.tag("library")
NavigationStack {
AnalysisHomeView()
.background(Color.zxCanvas.ignoresSafeArea())
}
.tabItem {
Label("分析", systemImage: "chart.bar")
}
.tag("analysis")
NavigationStack {
ProfileView()
.background(Color.zxCanvas.ignoresSafeArea())
}
.tabItem {
Label("我的", systemImage: "person.crop.circle")
}
.tag("profile")
} }
.animation(.easeInOut(duration: 0.2), value: selectedTab) .tint(Color.zxPrimary)
.ignoresSafeArea(edges: .bottom)
} }
} }
struct ZXTabBar: View { // MARK: - Shared Components
@Binding var active: String
private let items = [
("ai", "AI", "brain.head.profile"),
("library", "知识库", "books.vertical"),
("study", "学习", "bolt"),
("analysis", "分析", "chart.bar"),
("profile", "我的", "person"),
]
var body: some View {
HStack(spacing: 0) {
ForEach(items, id: \.0) { item in
let on = item.0 == active
Button {
active = item.0
} label: {
VStack(spacing: 4) {
ZStack(alignment: .top) {
if on {
Capsule()
.fill(Color.zxPurple)
.frame(width: 20, height: 3)
.offset(y: -4)
}
Image(systemName: on ? "\(item.2).fill" : item.2)
.font(.system(size: 22))
.foregroundColor(on ? Color.zxPurple : Color.zxF03)
}
Text(item.1)
.font(.system(size: 10, weight: on ? .semibold : .regular))
.foregroundColor(on ? Color.zxPurple : Color.zxF03)
}
.frame(maxWidth: .infinity)
}
.accessibilityLabel("\(item.1)标签")
}
}
.padding(.top, 6)
.padding(.bottom, 34)
.frame(height: 83)
.background(.ultraThinMaterial)
.background(Color.zxBg0.opacity(0.95))
.overlay(alignment: .top) {
Rectangle().fill(Color.zxBorder008).frame(height: 1)
}
}
}
struct ZXIconBtn: View { struct ZXIconBtn: View {
let icon: String; let size: CGFloat; var branded = false; let action: () -> Void let icon: String; let size: CGFloat; var branded = false; let action: () -> Void

View File

@ -5,10 +5,23 @@ extension Notification.Name {
static let tokenExpired = Notification.Name("cloud.longde.AIStudyApp.tokenExpired") static let tokenExpired = Notification.Name("cloud.longde.AIStudyApp.tokenExpired")
} }
enum AppSession: Equatable {
case unknown
case unauthenticated
case authenticating
case authenticated
case refreshing
case expired
case disabled
case deleted
}
@MainActor @MainActor
final class AuthManager: ObservableObject { final class AuthManager: ObservableObject {
@Published var isAuthenticated = false @Published var session: AppSession = .unknown
@Published var isRestoring = true
var isLoading: Bool { session == .unknown }
var isLoggedIn: Bool { session == .authenticated }
static let shared = AuthManager() static let shared = AuthManager()
@ -30,12 +43,13 @@ final class AuthManager: ObservableObject {
} }
} }
// MARK: - Session restore
func restoreSession() async { func restoreSession() async {
isRestoring = true session = .unknown
defer { isRestoring = false }
guard let token = KeychainHelper.getAccessToken() else { guard let token = KeychainHelper.getAccessToken() else {
isAuthenticated = false session = .unauthenticated
return return
} }
@ -43,8 +57,9 @@ final class AuthManager: ObservableObject {
do { do {
let _: UserProfileResponse = try await APIClient.shared.request("/users/me") let _: UserProfileResponse = try await APIClient.shared.request("/users/me")
isAuthenticated = true session = .authenticated
} catch { } catch {
session = .refreshing
if let refreshed = await tryRefresh() { if let refreshed = await tryRefresh() {
await APIClient.shared.setToken(refreshed.accessToken) await APIClient.shared.setToken(refreshed.accessToken)
KeychainHelper.save( KeychainHelper.save(
@ -52,25 +67,30 @@ final class AuthManager: ObservableObject {
refreshToken: refreshed.refreshToken, refreshToken: refreshed.refreshToken,
userId: refreshed.user?.id ?? "" userId: refreshed.user?.id ?? ""
) )
isAuthenticated = true session = .authenticated
} else { } else {
await APIClient.shared.setToken(nil) await APIClient.shared.setToken(nil)
KeychainHelper.clear() KeychainHelper.clear()
isAuthenticated = false session = .expired
} }
} }
} }
// MARK: - Sign in
func signIn(_ response: AuthResponse) async { func signIn(_ response: AuthResponse) async {
session = .authenticating
await APIClient.shared.setToken(response.accessToken) await APIClient.shared.setToken(response.accessToken)
KeychainHelper.save( KeychainHelper.save(
accessToken: response.accessToken, accessToken: response.accessToken,
refreshToken: response.refreshToken, refreshToken: response.refreshToken,
userId: response.user?.id ?? "" userId: response.user?.id ?? ""
) )
isAuthenticated = true session = .authenticated
} }
// MARK: - Sign out
func signOut() async { func signOut() async {
if let refreshToken = KeychainHelper.getRefreshToken() { if let refreshToken = KeychainHelper.getRefreshToken() {
let body = RefreshRequest(refreshToken: refreshToken) let body = RefreshRequest(refreshToken: refreshToken)
@ -80,14 +100,31 @@ final class AuthManager: ObservableObject {
} }
await APIClient.shared.setToken(nil) await APIClient.shared.setToken(nil)
KeychainHelper.clear() KeychainHelper.clear()
isAuthenticated = false session = .unauthenticated
} }
// MARK: - Force session update from error code
func applyErrorCode(_ code: String?) {
switch code {
case "AUTH_USER_DISABLED":
session = .disabled
case "AUTH_USER_DELETED":
session = .deleted
case "AUTH_REFRESH_TOKEN_EXPIRED", "AUTH_REFRESH_TOKEN_REVOKED":
session = .expired
default:
break
}
}
// MARK: - Private
private func handleUnauthorized() { private func handleUnauthorized() {
Task { Task {
await APIClient.shared.setToken(nil) await APIClient.shared.setToken(nil)
KeychainHelper.clear() KeychainHelper.clear()
isAuthenticated = false session = .unauthenticated
} }
} }

View File

@ -90,6 +90,7 @@ struct AuthUser: Codable, Identifiable {
struct AppleAuthRequest: Codable { struct AppleAuthRequest: Codable {
let identityToken: String let identityToken: String
let authorizationCode: String?
let fullName: AppleFullName? let fullName: AppleFullName?
let nonce: String? let nonce: String?

View File

@ -107,23 +107,40 @@ actor APIClient {
// MARK: - Server error decoding // MARK: - Server error decoding
/// message /// message errorCode
func extractErrorInfo(_ data: Data) -> (message: String?, errorCode: String?) {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return (nil, nil)
}
return (
message: json["message"] as? String,
errorCode: json["errorCode"] as? String
)
}
private func decodeServerError(_ data: Data, fallback: APIError) -> APIError { private func decodeServerError(_ data: Data, fallback: APIError) -> APIError {
if let serverMsg = extractServerMessage(data) { let info = extractErrorInfo(data)
return APIError.serverError(serverMsg) if let serverMsg = info.message {
return APIError.serverError(serverMsg, code: info.errorCode)
} }
return fallback return fallback
} }
}
private func extractServerMessage(_ data: Data) -> String? { // MARK: - Server error info (public for AuthManager)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
struct ServerErrorInfo {
let message: String
let errorCode: String?
}
extension APIClient {
nonisolated func parseErrorInfo(from data: Data) -> ServerErrorInfo? {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let msg = json["message"] as? String else {
return nil return nil
} }
// NestJS GlobalExceptionFilter : { success, statusCode, message } return ServerErrorInfo(message: msg, errorCode: json["errorCode"] as? String)
if let msg = json["message"] as? String, !msg.isEmpty {
return msg
}
return nil
} }
// MARK: - Decoding // MARK: - Decoding

View File

@ -10,7 +10,13 @@ enum APIError: LocalizedError {
case decodingFailed(String) case decodingFailed(String)
case networkError(Error) case networkError(Error)
case unauthorized case unauthorized
case serverError(String) case serverError(String, code: String? = nil)
/// AUTH_USER_DISABLED AppSession
var errorCode: String? {
if case .serverError(_, let code) = self { return code }
return nil
}
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
@ -19,7 +25,7 @@ enum APIError: LocalizedError {
case .decodingFailed(let msg): return "数据解析失败: \(msg)" case .decodingFailed(let msg): return "数据解析失败: \(msg)"
case .networkError(let e): return e.localizedDescription case .networkError(let e): return e.localizedDescription
case .unauthorized: return "未授权,请重新登录" case .unauthorized: return "未授权,请重新登录"
case .serverError(let msg): return msg case .serverError(let msg, _): return msg
} }
} }
} }

View File

@ -30,9 +30,10 @@ class AuthService {
static let shared = AuthService() static let shared = AuthService()
private let client = APIClient.shared private let client = APIClient.shared
func appleLogin(identityToken: String, givenName: String?, familyName: String?, nonce: String? = nil) async throws -> AuthResponse { func appleLogin(identityToken: String, authorizationCode: String? = nil, givenName: String?, familyName: String?, nonce: String? = nil) async throws -> AuthResponse {
let body = AppleAuthRequest( let body = AppleAuthRequest(
identityToken: identityToken, identityToken: identityToken,
authorizationCode: authorizationCode,
fullName: givenName != nil fullName: givenName != nil
? AppleAuthRequest.AppleFullName(givenName: givenName, familyName: familyName) ? AppleAuthRequest.AppleFullName(givenName: givenName, familyName: familyName)
: nil, : nil,

View File

@ -4,7 +4,7 @@ struct AnalysisHomeView: View {
@StateObject private var viewModel = ActivityViewModel() @StateObject private var viewModel = ActivityViewModel()
var body: some View { var body: some View {
ZStack { ZStack {
Color.zxBg0.ignoresSafeArea() Color.zxCanvas.ignoresSafeArea()
VStack(spacing: 0) { VStack(spacing: 0) {
HStack { HStack {
Text("学习分析").font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxF0).tracking(-0.5) Text("学习分析").font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxF0).tracking(-0.5)
@ -29,6 +29,16 @@ struct AnalysisHomeView: View {
HStack { Text("掌握度趋势").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0); Spacer(); Text("↑ +8% 本周").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxGreen) } HStack { Text("掌握度趋势").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0); Spacer(); Text("↑ +8% 本周").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxGreen) }
ZXChartView() ZXChartView()
}.padding(16).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20)) }.padding(16).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20))
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)
}
}.padding(16).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20))
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill").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) }.accessibilityLabel("查看全部薄弱知识点") } HStack { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill").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) }.accessibilityLabel("查看全部薄弱知识点") }
ForEach(viewModel.focusItems.prefix(5)) { item in ForEach(viewModel.focusItems.prefix(5)) { item in
@ -101,3 +111,34 @@ struct ZXChartView: View {
.animation(reduceMotion ? nil : .default, value: showChart) .animation(reduceMotion ? nil : .default, value: showChart)
} }
} }
struct ZXWeekBarChart: View {
let data: [(String, CGFloat)] = [("", 0.3), ("", 0.5), ("", 0.7), ("", 0.45), ("", 0.8), ("", 0.9), ("", 0.6)]
@State private var show = false
var body: some View {
VStack(spacing: 8) {
HStack(alignment: .bottom, spacing: 8) {
ForEach(data, id: \.0) { d in
VStack(spacing: 6) {
RoundedRectangle(cornerRadius: 6)
.fill(
LinearGradient(
colors: [Color.zxPurple.opacity(0.8), Color.zxAccent.opacity(0.6)],
startPoint: .top, endPoint: .bottom
)
)
.frame(height: show ? d.1 * 80 : 4)
.animation(.spring(response: 0.6, dampingFraction: 0.7), value: show)
Text(d.0)
.font(.system(size: 10))
.foregroundColor(Color.zxF03)
}
.frame(maxWidth: .infinity)
}
}
}
.frame(height: 100)
.onAppear { show = true }
}
}

View File

@ -6,45 +6,237 @@ struct StudyHomeView: View {
@StateObject private var reviewVM = ReviewViewModel() @StateObject private var reviewVM = ReviewViewModel()
var body: some View { var body: some View {
ZStack { ZXGradient.page.ignoresSafeArea() ZStack {
ScrollView { VStack(spacing: 16) { Color.zxCanvas.ignoresSafeArea()
HStack { VStack(alignment: .leading, spacing: 2) { Text("学习工作台").font(.system(size: 20, weight: .heavy)).foregroundColor(Color.zxF0).tracking(-0.4) }; Spacer()
if studyVM.isLoading { ZXLoadingView(size: 22, lineWidth: 2) } ScrollView {
HStack(spacing: 4) { Image(systemName: "flame.fill").font(.system(size: 14)).foregroundColor(Color.zxOrange); Text("14 天连续").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)) } VStack(spacing: 16) {
.padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 4) // MARK: - Header
pc HStack {
VStack(alignment: .leading, spacing: 12) { HStack { Text("今日任务").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0); Spacer(); HStack(spacing: 4) { Image(systemName: "calendar").font(.system(size: 12)).foregroundColor(Color.zxF04); Text("AI 自动排期").font(.system(size: 12)).foregroundColor(Color.zxF04) } } Spacer()
ForEach($studyHomeVM.tasks) { $t in if studyVM.isLoading {
if t.tp == "回忆测试" { ZXLoadingView(size: 22, lineWidth: 2)
NavigationLink(value: Route.activeRecall) { ZXSTaskRowView(task: t) { t.d.toggle() } }.foregroundColor(.primary) }
} else if t.tp == "费曼练习" { HStack(spacing: 4) {
NavigationLink(value: Route.aiChat) { ZXSTaskRowView(task: t) { t.d.toggle() } }.foregroundColor(.primary) Image(systemName: "flame.fill")
} else if t.tp == "薄弱点" { .font(.system(size: 14))
NavigationLink(value: Route.weakPoints) { ZXSTaskRowView(task: t) { t.d.toggle() } }.foregroundColor(.primary) .foregroundColor(Color.zxOrange)
} else if t.tp == "间隔复习" { Text("14 天连续")
NavigationLink(value: Route.reviewCard) { ZXSTaskRowView(task: t) { t.d.toggle() } }.foregroundColor(.primary) .font(.system(size: 13, weight: .bold))
} else { .foregroundColor(Color.zxOrange)
NavigationLink(value: Route.learningSession(taskTitle: t.t, taskType: t.tp, taskColorHex: t.ch)) { ZXSTaskRowView(task: t) { t.d.toggle() } }.foregroundColor(.primary) }
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.zxOrangeBG(0.1))
.clipShape(Capsule())
.overlay(Capsule().stroke(Color(hex: "#F97316", opacity: 0.2), lineWidth: 1))
}
.padding(.horizontal, 20)
.padding(.top, ZXSpacing.statusBarH + 16)
.padding(.bottom, 4)
// MARK: - Progress
progressCard
// MARK: - Today's Tasks
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("今日任务")
.font(.system(size: 15, weight: .bold))
.foregroundColor(Color.zxF0)
Spacer()
HStack(spacing: 4) {
Image(systemName: "calendar")
.font(.system(size: 12))
.foregroundColor(Color.zxF04)
Text("AI 自动排期")
.font(.system(size: 12))
.foregroundColor(Color.zxF04)
}
}
ForEach($studyHomeVM.tasks) { $t in
if t.tp == "回忆测试" {
NavigationLink(value: Route.activeRecall) {
ZXSTaskRowView(task: t) { t.d.toggle() }
}
.foregroundColor(.primary)
} else if t.tp == "费曼练习" {
NavigationLink(value: Route.aiChat) {
ZXSTaskRowView(task: t) { t.d.toggle() }
}
.foregroundColor(.primary)
} else if t.tp == "薄弱点" {
NavigationLink(value: Route.weakPoints) {
ZXSTaskRowView(task: t) { t.d.toggle() }
}
.foregroundColor(.primary)
} else if t.tp == "间隔复习" {
NavigationLink(value: Route.reviewCard) {
ZXSTaskRowView(task: t) { t.d.toggle() }
}
.foregroundColor(.primary)
} else {
NavigationLink(value: Route.learningSession(taskTitle: t.t, taskType: t.tp, taskColorHex: t.ch)) {
ZXSTaskRowView(task: t) { t.d.toggle() }
}
.foregroundColor(.primary)
}
} }
} }
.padding(.horizontal, 20)
// MARK: - Daily Thinking
dailyThinkingCard
.padding(.horizontal, 20)
// Bottom spacer
Color.clear.frame(height: 100)
} }
VStack(alignment: .leading, spacing: 14) { Text("本周学习活跃").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0)
HStack(alignment: .bottom, spacing: 8) { ForEach(0..<7, id: \.self) { i in VStack(spacing: 8) { RoundedRectangle(cornerRadius: 6).fill(i == 6 ? Color.zxFill01 : Color(hex: "#7C6EFA", opacity: studyHomeVM.weekActivity[i] * 0.9 + 0.1)).frame(height: studyHomeVM.weekActivity[i] * 60).animation(.spring(response: 0.6, dampingFraction: 0.7).delay(Double(i) * 0.05), value: studyHomeVM.weekActivity[i]); Text(studyHomeVM.dayLabels[i]).font(.system(size: 10, weight: i == 2 ? .bold : .regular)).foregroundColor(i == 2 ? Color.zxPurple : Color.zxF03) }.frame(maxWidth: .infinity) } }
HStack { Text("总计 3.5 小时").font(.system(size: 11)).foregroundColor(Color.zxF03); Spacer(); Text("日均 30 分钟").font(.system(size: 11)).foregroundColor(Color.zxF03) } }
.padding(.bottom, 120) }
.padding(.horizontal, 20) }
.scrollIndicators(.hidden)
.zxPullToRefresh { await studyVM.loadSessions() }
} }
.task { await studyVM.loadSessions() } .scrollIndicators(.hidden)
.navigationDestination(for: Route.self) { $0.destination } .zxPullToRefresh { await studyVM.loadSessions() }
}
.task { await studyVM.loadSessions() }
.navigationDestination(for: Route.self) { $0.destination }
} }
private var pc: some View { let dn = studyHomeVM.doneCount; let pct = CGFloat(dn) / 5
return VStack(spacing: 12) { HStack { VStack(alignment: .leading, spacing: 2) { Text("今日进度").font(.system(size: 13, weight: .medium)).foregroundColor(Color.zxF05); HStack(alignment: .lastTextBaseline, spacing: 6) { Text("\(dn)").font(.system(size: 26, weight: .black)).foregroundColor(Color.zxF0).contentTransition(.numericText()); Text("/ 5"); Text("个任务").font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxF04) } }; Spacer() // MARK: - Progress Card
ZStack { Circle().trim(from: 0, to: pct).stroke(ZXGradient.brand, style: StrokeStyle(lineWidth: 8, lineCap: .round)).rotationEffect(.degrees(-90)).frame(width: 64, height: 64).animation(.easeInOut(duration: 0.8), value: pct); Text("\(Int(pct * 100))%").font(.system(size: 14, weight: .heavy)).foregroundColor(Color.zxPurple).contentTransition(.numericText()) } }
ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 3).fill(Color.zxFill008).frame(height: 6); RoundedRectangle(cornerRadius: 3).fill(LinearGradient(colors: [Color.zxPurple, Color.zxAccent], startPoint: .leading, endPoint: .trailing)).frame(width: max(6, pct * (UIScreen.main.bounds.width - 72)), height: 6).animation(.easeInOut(duration: 0.6), value: pct) } private var progressCard: some View {
HStack { VStack(alignment: .leading, spacing: 2) { Text("\(dn * 12) 分钟").font(.system(size: 13, weight: .bold)).foregroundColor(Color.zxF0); Text("已学").font(.system(size: 10)).foregroundColor(Color.zxF04) }; Spacer(); VStack(spacing: 2) { Text("\((5 - dn) * 11) 分钟").font(.system(size: 13, weight: .bold)).foregroundColor(Color.zxF0); Text("剩余").font(.system(size: 10)).foregroundColor(Color.zxF04) }; Spacer(); VStack(alignment: .trailing, spacing: 2) { Text("+5 点").font(.system(size: 13, weight: .bold)).foregroundColor(Color.zxF0); Text("掌握").font(.system(size: 10)).foregroundColor(Color.zxF04) } } } let dn = studyHomeVM.doneCount
.padding(16).background(ZXGradient.progressCard).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "#7C6EFA", opacity: 0.15), lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20)) } let pct = CGFloat(dn) / 5
return VStack(spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("今日进度")
.font(.system(size: 13, weight: .medium))
.foregroundColor(Color.zxF05)
HStack(alignment: .lastTextBaseline, spacing: 6) {
Text("\(dn)")
.font(.system(size: 26, weight: .black))
.foregroundColor(Color.zxF0)
.contentTransition(.numericText())
Text("/ 5")
Text("个任务")
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.zxF04)
}
}
Spacer()
ZStack {
Circle()
.trim(from: 0, to: pct)
.stroke(ZXGradient.brand, style: StrokeStyle(lineWidth: 8, lineCap: .round))
.rotationEffect(.degrees(-90))
.frame(width: 64, height: 64)
.animation(.easeInOut(duration: 0.8), value: pct)
Text("\(Int(pct * 100))%")
.font(.system(size: 14, weight: .heavy))
.foregroundColor(Color.zxPurple)
.contentTransition(.numericText())
}
}
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 3)
.fill(Color.zxFill008)
.frame(height: 6)
RoundedRectangle(cornerRadius: 3)
.fill(LinearGradient(colors: [Color.zxPurple, Color.zxAccent], startPoint: .leading, endPoint: .trailing))
.frame(width: max(6, pct * (UIScreen.main.bounds.width - 72)), height: 6)
.animation(.easeInOut(duration: 0.6), value: pct)
}
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("\(dn * 12) 分钟")
.font(.system(size: 13, weight: .bold))
.foregroundColor(Color.zxF0)
Text("已学")
.font(.system(size: 10))
.foregroundColor(Color.zxF04)
}
Spacer()
VStack(spacing: 2) {
Text("\((5 - dn) * 11) 分钟")
.font(.system(size: 13, weight: .bold))
.foregroundColor(Color.zxF0)
Text("剩余")
.font(.system(size: 10))
.foregroundColor(Color.zxF04)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("+5 点")
.font(.system(size: 13, weight: .bold))
.foregroundColor(Color.zxF0)
Text("掌握")
.font(.system(size: 10))
.foregroundColor(Color.zxF04)
}
}
}
.padding(16)
.background(ZXGradient.progressCard)
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "#7C6EFA", opacity: 0.15), lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 20))
.padding(.horizontal, 20)
}
// MARK: - Daily Thinking Card
private var dailyThinkingCard: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Image(systemName: "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))
}
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))
}
} }
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 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) } }