diff --git a/AIStudyApp/AIStudyApp/AIStudyAppApp.swift b/AIStudyApp/AIStudyApp/AIStudyAppApp.swift index e2626ba..85a108b 100644 --- a/AIStudyApp/AIStudyApp/AIStudyAppApp.swift +++ b/AIStudyApp/AIStudyApp/AIStudyAppApp.swift @@ -18,9 +18,11 @@ struct AIStudyAppApp: App { var body: some Scene { WindowGroup { Group { - if authManager.isRestoring { + switch authManager.session { + case .unknown: SplashScreen() - } else if authManager.isAuthenticated { + + case .authenticated: if hasCompletedOnboarding { ContentView() .environmentObject(authManager) @@ -28,9 +30,29 @@ struct AIStudyAppApp: App { PostLoginOnboardingFlow(hasCompletedOnboarding: $hasCompletedOnboarding) .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() .environmentObject(authManager) + + case .authenticating, .refreshing: + SplashScreen() } } .preferredColorScheme(effectiveColorScheme) @@ -253,6 +275,7 @@ struct LoginPage: View { } let givenName = credential.fullName?.givenName let familyName = credential.fullName?.familyName + let authCode = credential.authorizationCode.flatMap { String(data: $0, encoding: .utf8) } isLoggingIn = true errorMessage = nil @@ -264,6 +287,7 @@ struct LoginPage: View { LoginPage.currentRawNonce = nil // 用完即清,防重放 let resp = try await AuthService.shared.appleLogin( identityToken: tokenStr, + authorizationCode: authCode, givenName: givenName, familyName: familyName, 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 struct GoalSetupPage: View { diff --git a/AIStudyApp/AIStudyApp/ContentView.swift b/AIStudyApp/AIStudyApp/ContentView.swift index c8adee2..9844b07 100644 --- a/AIStudyApp/AIStudyApp/ContentView.swift +++ b/AIStudyApp/AIStudyApp/ContentView.swift @@ -1,72 +1,51 @@ import SwiftUI struct ContentView: View { - @State private var selectedTab = "ai" + @State private var selectedTab = "study" + var body: some View { - ZStack { - switch selectedTab { - case "ai": NavigationStack { AIHomeView().background(Color.zxBg0.ignoresSafeArea()) } - case "library": NavigationStack { LibraryHomeView().background(Color.zxBg0.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() } + TabView(selection: $selectedTab) { + NavigationStack { + StudyHomeView() + .background(Color.zxCanvas.ignoresSafeArea()) } - 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) - .ignoresSafeArea(edges: .bottom) + .tint(Color.zxPrimary) } } -struct ZXTabBar: View { - @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) - } - } -} +// MARK: - Shared Components struct ZXIconBtn: View { let icon: String; let size: CGFloat; var branded = false; let action: () -> Void @@ -140,4 +119,4 @@ struct ZXAIInputBar: View { .overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder008, lineWidth: 1)) .clipShape(RoundedRectangle(cornerRadius: 20)) } -} +} \ No newline at end of file diff --git a/AIStudyApp/AIStudyApp/Core/Auth/AuthManager.swift b/AIStudyApp/AIStudyApp/Core/Auth/AuthManager.swift index 2a50d35..b2d5200 100644 --- a/AIStudyApp/AIStudyApp/Core/Auth/AuthManager.swift +++ b/AIStudyApp/AIStudyApp/Core/Auth/AuthManager.swift @@ -5,10 +5,23 @@ extension Notification.Name { 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 final class AuthManager: ObservableObject { - @Published var isAuthenticated = false - @Published var isRestoring = true + @Published var session: AppSession = .unknown + + var isLoading: Bool { session == .unknown } + var isLoggedIn: Bool { session == .authenticated } static let shared = AuthManager() @@ -30,12 +43,13 @@ final class AuthManager: ObservableObject { } } + // MARK: - Session restore + func restoreSession() async { - isRestoring = true - defer { isRestoring = false } + session = .unknown guard let token = KeychainHelper.getAccessToken() else { - isAuthenticated = false + session = .unauthenticated return } @@ -43,8 +57,9 @@ final class AuthManager: ObservableObject { do { let _: UserProfileResponse = try await APIClient.shared.request("/users/me") - isAuthenticated = true + session = .authenticated } catch { + session = .refreshing if let refreshed = await tryRefresh() { await APIClient.shared.setToken(refreshed.accessToken) KeychainHelper.save( @@ -52,25 +67,30 @@ final class AuthManager: ObservableObject { refreshToken: refreshed.refreshToken, userId: refreshed.user?.id ?? "" ) - isAuthenticated = true + session = .authenticated } else { await APIClient.shared.setToken(nil) KeychainHelper.clear() - isAuthenticated = false + session = .expired } } } + // MARK: - Sign in + func signIn(_ response: AuthResponse) async { + session = .authenticating await APIClient.shared.setToken(response.accessToken) KeychainHelper.save( accessToken: response.accessToken, refreshToken: response.refreshToken, userId: response.user?.id ?? "" ) - isAuthenticated = true + session = .authenticated } + // MARK: - Sign out + func signOut() async { if let refreshToken = KeychainHelper.getRefreshToken() { let body = RefreshRequest(refreshToken: refreshToken) @@ -80,14 +100,31 @@ final class AuthManager: ObservableObject { } await APIClient.shared.setToken(nil) 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() { Task { await APIClient.shared.setToken(nil) KeychainHelper.clear() - isAuthenticated = false + session = .unauthenticated } } diff --git a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift index 076cdc4..294208d 100644 --- a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift +++ b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift @@ -90,6 +90,7 @@ struct AuthUser: Codable, Identifiable { struct AppleAuthRequest: Codable { let identityToken: String + let authorizationCode: String? let fullName: AppleFullName? let nonce: String? diff --git a/AIStudyApp/AIStudyApp/Core/Network/APIClient.swift b/AIStudyApp/AIStudyApp/Core/Network/APIClient.swift index 817f59c..8a42954 100644 --- a/AIStudyApp/AIStudyApp/Core/Network/APIClient.swift +++ b/AIStudyApp/AIStudyApp/Core/Network/APIClient.swift @@ -107,23 +107,40 @@ actor APIClient { // 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 { - if let serverMsg = extractServerMessage(data) { - return APIError.serverError(serverMsg) + let info = extractErrorInfo(data) + if let serverMsg = info.message { + return APIError.serverError(serverMsg, code: info.errorCode) } return fallback } +} - private func extractServerMessage(_ data: Data) -> String? { - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { +// MARK: - Server error info (public for AuthManager) + +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 } - // NestJS GlobalExceptionFilter 格式: { success, statusCode, message } - if let msg = json["message"] as? String, !msg.isEmpty { - return msg - } - return nil + return ServerErrorInfo(message: msg, errorCode: json["errorCode"] as? String) } // MARK: - Decoding diff --git a/AIStudyApp/AIStudyApp/Core/Network/APIError.swift b/AIStudyApp/AIStudyApp/Core/Network/APIError.swift index 82f8c85..b0ace58 100644 --- a/AIStudyApp/AIStudyApp/Core/Network/APIError.swift +++ b/AIStudyApp/AIStudyApp/Core/Network/APIError.swift @@ -10,7 +10,13 @@ enum APIError: LocalizedError { case decodingFailed(String) case networkError(Error) 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? { switch self { @@ -19,7 +25,7 @@ enum APIError: LocalizedError { case .decodingFailed(let msg): return "数据解析失败: \(msg)" case .networkError(let e): return e.localizedDescription case .unauthorized: return "未授权,请重新登录" - case .serverError(let msg): return msg + case .serverError(let msg, _): return msg } } } diff --git a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift index ad2f128..86e43a6 100644 --- a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift +++ b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift @@ -30,9 +30,10 @@ class AuthService { static let shared = AuthService() 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( identityToken: identityToken, + authorizationCode: authorizationCode, fullName: givenName != nil ? AppleAuthRequest.AppleFullName(givenName: givenName, familyName: familyName) : nil, diff --git a/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift b/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift index c184677..69d1609 100644 --- a/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift +++ b/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift @@ -4,7 +4,7 @@ struct AnalysisHomeView: View { @StateObject private var viewModel = ActivityViewModel() var body: some View { ZStack { - Color.zxBg0.ignoresSafeArea() + Color.zxCanvas.ignoresSafeArea() VStack(spacing: 0) { HStack { 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) } ZXChartView() }.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) { 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 @@ -101,3 +111,34 @@ struct ZXChartView: View { .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 } + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Study/StudyHomeView.swift b/AIStudyApp/AIStudyApp/Features/Study/StudyHomeView.swift index 54ebf32..4e12d50 100644 --- a/AIStudyApp/AIStudyApp/Features/Study/StudyHomeView.swift +++ b/AIStudyApp/AIStudyApp/Features/Study/StudyHomeView.swift @@ -6,45 +6,237 @@ struct StudyHomeView: View { @StateObject private var reviewVM = ReviewViewModel() var body: some View { - ZStack { ZXGradient.page.ignoresSafeArea() - ScrollView { VStack(spacing: 16) { - 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) } - 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)) } - .padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 4) - pc - 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) + ZStack { + Color.zxCanvas.ignoresSafeArea() + + ScrollView { + VStack(spacing: 16) { + // MARK: - Header + HStack { + Spacer() + if studyVM.isLoading { + ZXLoadingView(size: 22, lineWidth: 2) + } + 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)) + } + .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() } - .navigationDestination(for: Route.self) { $0.destination } + .scrollIndicators(.hidden) + .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() - 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)) } + + // MARK: - Progress Card + + private var progressCard: 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() + 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) } }