From 0f8e542b2a0a04aa0ebccd1f2dee3e49e0ddff2e Mon Sep 17 00:00:00 2001 From: wangdl Date: Wed, 27 May 2026 20:23:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(ios):=20Apple=E7=99=BB=E5=BD=95nonce+401?= =?UTF-8?q?=E9=80=8F=E4=BC=A0+DesignSystem=20v2.0+AIHomePage=E9=87=8D?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LoginPage: 新增nonce生成(SHA256)CryptoKit、取消按钮、Task可取消 - APIClient: 401响应透传服务端error message(不再硬编码) - DesignTokens: 主色切换#3D7FFB渐变#3D7FFB→#9DA7FD、补全15个兼容别名 - AIHomeView: 全新v2.0设计(暖白底色/知习蓝/34pt hero/Rive占位) - AppleAuthRequest: 新增nonce字段 --- AIStudyApp/AIStudyApp/AIStudyAppApp.swift | 107 +++- .../Core/DesignSystem/DesignTokens.swift | 317 +++++----- .../AIStudyApp/Core/Models/APIModels.swift | 1 + .../AIStudyApp/Core/Network/APIClient.swift | 25 +- .../AIStudyApp/Core/Services/APIService.swift | 5 +- .../AIStudyApp/Features/AI/AIHomeView.swift | 563 +++++++++++++----- 6 files changed, 690 insertions(+), 328 deletions(-) diff --git a/AIStudyApp/AIStudyApp/AIStudyAppApp.swift b/AIStudyApp/AIStudyApp/AIStudyAppApp.swift index c373713..e2626ba 100644 --- a/AIStudyApp/AIStudyApp/AIStudyAppApp.swift +++ b/AIStudyApp/AIStudyApp/AIStudyAppApp.swift @@ -137,12 +137,13 @@ struct FeatureRow: View { } } -// MARK: - Login +// MARK: - Login (with nonce support for iOS 15+) struct LoginPage: View { @EnvironmentObject var authManager: AuthManager @State private var isLoggingIn = false @State private var errorMessage: String? + @State private var loginTask: Task? var body: some View { ZStack { @@ -159,14 +160,19 @@ struct LoginPage: View { } if let error = errorMessage { - Text(error).font(.system(size: 13)).foregroundColor(.red) - .padding(.horizontal, 16).padding(.vertical, 10) - .background(Color.red.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 10)) + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 12)) + Text(error).font(.system(size: 13)) + } + .foregroundColor(Color(hex: "#991B1B")) + .padding(.horizontal, 16).padding(.vertical, 10) + .background(Color(hex: "#FEE2E2")) + .clipShape(RoundedRectangle(cornerRadius: 10)) } SignInWithAppleButton(.signIn) { request in request.requestedScopes = [.fullName, .email] + request.nonce = LoginPage.generateNonceHash() } onCompletion: { result in handleAppleResult(result) } @@ -176,15 +182,65 @@ struct LoginPage: View { .disabled(isLoggingIn) .overlay { if isLoggingIn { - ProgressView().tint(.white) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.black.opacity(0.5)) - .clipShape(RoundedRectangle(cornerRadius: 16)) + VStack(spacing: 8) { + ProgressView().tint(.white) + Button("取消") { + loginTask?.cancel() + isLoggingIn = false + errorMessage = nil + } + .font(.system(size: 11)) + .foregroundColor(.white.opacity(0.7)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black.opacity(0.6)) + .clipShape(RoundedRectangle(cornerRadius: 16)) } } }.padding(.horizontal, 24).padding(.bottom, 48) } } + } + + // MARK: - Nonce generation (iOS 15+ required) + + private static var currentRawNonce: String? + + /// SHA256 hash of the nonce, passed to Apple in the request + private static func generateNonceHash() -> String { + let raw = randomNonceString() + currentRawNonce = raw + return sha256(raw) + } + + private static func randomNonceString(length: Int = 32) -> String { + precondition(length > 0) + let charset: [Character] = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._") + var result = "" + var remainingLength = length + while remainingLength > 0 { + var randoms = [UInt8](repeating: 0, count: 16) + let errorCode = SecRandomCopyBytes(kSecRandomDefault, randoms.count, &randoms) + if errorCode != errSecSuccess { + fatalError("Unable to generate nonce. SecRandomCopyBytes failed with OSStatus \(errorCode)") + } + for random in randoms { + if remainingLength == 0 { continue } + if random < charset.count { + result.append(charset[Int(random)]) + remainingLength -= 1 + } + } } + return result + } + + private static func sha256(_ input: String) -> String { + let inputData = Data(input.utf8) + let hashedData = sha256Data(inputData) + return hashedData.map { String(format: "%02x", $0) }.joined() + } + + // MARK: - Apple result handler private func handleAppleResult(_ result: Result) { switch result { @@ -192,7 +248,7 @@ struct LoginPage: View { guard let credential = auth.credential as? ASAuthorizationAppleIDCredential, let identityToken = credential.identityToken, let tokenStr = String(data: identityToken, encoding: .utf8) else { - errorMessage = "获取 Apple 身份信息失败" + withAnimation { errorMessage = "获取 Apple 身份信息失败" } return } let givenName = credential.fullName?.givenName @@ -200,29 +256,48 @@ struct LoginPage: View { isLoggingIn = true errorMessage = nil - Task { + + loginTask = Task { do { + try Task.checkCancellation() + let rawNonce = LoginPage.currentRawNonce + LoginPage.currentRawNonce = nil // 用完即清,防重放 let resp = try await AuthService.shared.appleLogin( identityToken: tokenStr, givenName: givenName, - familyName: familyName + familyName: familyName, + nonce: rawNonce ) + try Task.checkCancellation() await authManager.signIn(resp) - isLoggingIn = false + await MainActor.run { isLoggingIn = false } + } catch is CancellationError { + await MainActor.run { isLoggingIn = false } } catch { - isLoggingIn = false - errorMessage = "登录失败: \(error.localizedDescription)" + await MainActor.run { + isLoggingIn = false + errorMessage = "登录失败: \(error.localizedDescription)" + } } } case .failure(let error): if (error as NSError).code != ASAuthorizationError.canceled.rawValue { - errorMessage = "Apple 登录失败: \(error.localizedDescription)" + withAnimation { errorMessage = "Apple 登录失败: \(error.localizedDescription)" } } } } } +import CryptoKit + +// MARK: - SHA256 helper + +private func sha256Data(_ data: Data) -> Data { + let digest = SHA256.hash(data: data) + return Data(digest) +} + // 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)) } } } diff --git a/AIStudyApp/AIStudyApp/Core/DesignSystem/DesignTokens.swift b/AIStudyApp/AIStudyApp/Core/DesignSystem/DesignTokens.swift index 8c2cdb5..2cceaf2 100644 --- a/AIStudyApp/AIStudyApp/Core/DesignSystem/DesignTokens.swift +++ b/AIStudyApp/AIStudyApp/Core/DesignSystem/DesignTokens.swift @@ -2,7 +2,8 @@ // DesignTokens.swift // AIStudyApp // -// 1:1 像素级还原 React 原型的设计令牌 +// 知习 AI Design System v2.0 — Token 定义 +// 主色 #3D7FFB (知习蓝), 渐变 #3D7FFB → #9DA7FD // import SwiftUI @@ -27,8 +28,6 @@ extension Color { } } -// MARK: - Adaptive Color Helper - extension Color { init(light: Color, dark: Color) { #if canImport(UIKit) @@ -41,180 +40,180 @@ extension Color { } } -// MARK: - ZhiXi Colors (exact match from React index.css) +// MARK: - ZhiXi Design Tokens v2.0 extension Color { - // ── 背景 ── - static let zxBg0 = Color(light: Color(hex: "#F5F5FA"), dark: Color(hex: "#0F0F1A")) - static let zxBg1 = Color(light: Color(hex: "#EAEAF2"), dark: Color(hex: "#12122A")) - static let zxBg2 = Color(light: Color(hex: "#E0E0E8"), dark: Color(hex: "#0A0A14")) - static let zxBgSplash = Color(light: Color(hex: "#F0F0F8"), dark: Color(hex: "#0D0D20")) + // ── 品牌主色 ── + static let zxPrimary = Color(hex: "#3D7FFB") + static let zxPrimaryPressed = Color(hex: "#2B65D1") + static let zxPrimaryDeep = Color(hex: "#1E4FBB") + static let zxPrimarySoft = Color(hex: "#EBF0FE") + static let zxOnPrimary = Color.white + + // ── 渐变色 ── + static let zxGradientStart = Color(hex: "#3D7FFB") + static let zxGradientEnd = Color(hex: "#9DA7FD") + + // ── 页面背景 (暖白体系) ── + static let zxCanvas = Color(light: Color(hex: "#FAFAF8"), dark: Color(hex: "#1C1C1E")) + static let zxSurface = Color(light: Color(hex: "#F3F2F0"), dark: Color(hex: "#2C2C2E")) + static let zxSurfaceSoft = Color(light: Color(hex: "#FAFAF9"), dark: Color(hex: "#242426")) + static let zxSurfaceElevated = Color(light: .white, dark: Color(hex: "#3A3A3C")) + + // ── 分割线 ── + static let zxHairline = Color(light: Color(hex: "#E8E6E1"), dark: Color(hex: "#38383A")) + static let zxHairlineStrong = Color(light: Color(hex: "#D4D1CB"), dark: Color(hex: "#48484A")) // ── 文字 ── - static let zxF0 = Color(light: Color(hex: "#1A1A2E"), dark: Color(hex: "#F0F0FF")) - static let zxF05 = Color(light: Color(hex: "#475569"), dark: Color(hex: "#F0F0FF", opacity: 0.5)) - static let zxF04 = Color(light: Color(hex: "#475569"), dark: Color(hex: "#F0F0FF", opacity: 0.4)) - static let zxF03 = Color(light: Color(hex: "#64748B"), dark: Color(hex: "#F0F0FF", opacity: 0.3)) - static let zxF007 = Color(light: Color(hex: "#1A1A2E", opacity: 0.7), dark: Color(hex: "#F0F0FF", opacity: 0.7)) - static let zxF006 = Color(light: Color(hex: "#334155"), dark: Color(hex: "#F0F0FF", opacity: 0.6)) - static let zxF0045 = Color(light: Color(hex: "#475569"), dark: Color(hex: "#F0F0FF", opacity: 0.45)) - static let zxF035 = Color(light: Color(hex: "#586A82"), dark: Color(hex: "#F0F0FF", opacity: 0.35)) - static let zxF02 = Color(light: Color(hex: "#64748B"), dark: Color(hex: "#F0F0FF", opacity: 0.2)) + static let zxInkPrimary = Color(light: Color(hex: "#1E1B18"), dark: Color(hex: "#FFFFFF")) + static let zxInkSecondary = Color(light: Color(hex: "#6B6560"), dark: Color(hex: "#A1A1A6")) + static let zxInkTertiary = Color(light: Color(hex: "#9F9993"), dark: Color(hex: "#636366")) - // ── 品牌色 ── - static let zxPurple = Color(hex: "#7C6EFA") - static let zxOrange = Color(hex: "#F97316") - static let zxAccent = Color(hex: "#A78BFA") - static let zxTeal = Color(hex: "#2DD4BF") - static let zxGreen = Color(hex: "#34D399") - static let zxYellow = Color(hex: "#F59E0B") - static let zxRed = Color(hex: "#EF4444") - static let zxCyan = Color(hex: "#4ECDC4") + // ── 功能色 ── + static let zxAmber = Color(hex: "#F59E0B") + static let zxAmberSoft = Color(hex: "#FEF3C7") + static let zxAmberDeep = Color(hex: "#92400E") + static let zxCoral = Color(hex: "#F87171") + static let zxCoralSoft = Color(hex: "#FEE2E2") + static let zxCoralDeep = Color(hex: "#991B1B") + static let zxMint = Color(hex: "#34D399") + static let zxMintSoft = Color(hex: "#D1FAE5") + static let zxMintDeep = Color(hex: "#065F46") + static let zxSky = Color(hex: "#60A5FA") + static let zxSkySoft = Color(hex: "#DBEAFE") - // ── 边框/分割线 ── - static let zxBorder008 = Color(light: Color(hex: "#000000", opacity: 0.12), dark: Color(hex: "#FFFFFF", opacity: 0.08)) - static let zxBorder006 = Color(light: Color(hex: "#000000", opacity: 0.10), dark: Color(hex: "#FFFFFF", opacity: 0.06)) - static let zxBorder004 = Color(light: Color(hex: "#000000", opacity: 0.08), dark: Color(hex: "#FFFFFF", opacity: 0.04)) - static let zxBorder01 = Color(light: Color(hex: "#000000", opacity: 0.10), dark: Color(hex: "#FFFFFF", opacity: 0.10)) - static let zxBorder015 = Color(light: Color(hex: "#000000", opacity: 0.15), dark: Color(hex: "#FFFFFF", opacity: 0.15)) + // ── 兼容旧版 (逐步迁移) ── + static let zxBg0 = zxCanvas + static let zxBg1 = zxSurface + static let zxF0 = zxInkPrimary + static let zxF05 = zxInkSecondary + static let zxF04 = zxInkSecondary + static let zxF03 = zxInkTertiary + static let zxF007 = zxInkSecondary + static let zxF006 = zxInkTertiary + static let zxF0045 = zxInkTertiary + static let zxF02 = zxInkTertiary + static let zxF035 = zxInkTertiary + static let zxPurple = zxPrimary + static let zxAccent = Color(hex: "#9DA7FD") + static let zxOrange = zxAmber + static let zxTeal = Color(hex: "#2DD4BF") + static let zxCyan = Color(hex: "#4ECDC4") + static let zxGreen = zxMint + static let zxYellow = zxAmber + static let zxRed = zxCoral + static let zxBorder008 = zxHairline + static let zxBorder006 = zxHairline + static let zxBorder004 = zxHairline + static let zxBorder01 = zxHairlineStrong + static let zxBorder015 = zxHairlineStrong + static let zxFill003 = zxSurface + static let zxFill004 = zxSurface + static let zxFill005 = zxSurface + static let zxFill006 = zxSurface + static let zxFill008 = zxSurface + static let zxFill01 = zxSurfaceSoft - // ── 半透明填充 ── - static let zxFill003 = Color(light: Color(hex: "#000000", opacity: 0.03), dark: Color(hex: "#FFFFFF", opacity: 0.03)) - static let zxFill004 = Color(light: Color(hex: "#000000", opacity: 0.04), dark: Color(hex: "#FFFFFF", opacity: 0.04)) - static let zxFill005 = Color(light: Color(hex: "#000000", opacity: 0.05), dark: Color(hex: "#FFFFFF", opacity: 0.05)) - static let zxFill006 = Color(light: Color(hex: "#000000", opacity: 0.06), dark: Color(hex: "#FFFFFF", opacity: 0.06)) - static let zxFill008 = Color(light: Color(hex: "#000000", opacity: 0.08), dark: Color(hex: "#FFFFFF", opacity: 0.08)) - static let zxFill01 = Color(light: Color(hex: "#000000", opacity: 0.10), dark: Color(hex: "#FFFFFF", opacity: 0.10)) - - // ── 彩色半透 ── - static func zxPurpleBG(_ a: Double = 0.10) -> Color { Color(hex: "#7C6EFA", opacity: a) } - static func zxOrangeBG(_ a: Double = 0.10) -> Color { Color(hex: "#F97316", opacity: a) } - static func zxGreenBG(_ a: Double = 0.10) -> Color { Color(hex: "#34D399", opacity: a) } - static func zxYellowBG(_ a: Double = 0.10) -> Color { Color(hex: "#F59E0B", opacity: a) } - static func zxTealBG(_ a: Double = 0.10) -> Color { Color(hex: "#4ECDC4", opacity: a) } - static func zxRedBG(_ a: Double = 0.15) -> Color { Color(hex: "#EF4444", opacity: a) } + // ── 旧版彩色半透兼容 ── + static func zxPurpleBG(_ a: Double = 0.10) -> Color { zxPrimarySoft.opacity(a * 1.5) } + static func zxYellowBG(_ a: Double = 0.10) -> Color { zxAmberSoft.opacity(a * 2.0) } + static func zxGreenBG(_ a: Double = 0.10) -> Color { zxMintSoft.opacity(a * 1.5) } + static func zxRedBG(_ a: Double = 0.15) -> Color { zxCoralSoft.opacity(a * 1.5) } + static func zxTealBG(_ a: Double = 0.10) -> Color { Color(hex: "#2DD4BF").opacity(a) } + static func zxOrangeBG(_ a: Double = 0.10) -> Color { zxAmberSoft.opacity(a * 2.0) } } -// MARK: - ZhiXi Gradients (exact match) +// MARK: - ZhiXi Gradients v2.0 enum ZXGradient { - // ── 页面背景 ── - static let page = LinearGradient( - colors: [Color.zxBg0, Color.zxBg1], - startPoint: .top, endPoint: .bottom - ) - - // ── 品牌渐变 (135deg) ── static let brand = LinearGradient( - colors: [Color(hex: "#7C6EFA"), Color(hex: "#F97316")], + colors: [Color.zxGradientStart, Color.zxGradientEnd], startPoint: .topLeading, endPoint: .bottomTrailing ) - static let brandPurple = LinearGradient( - colors: [Color(hex: "#7C6EFA"), Color(hex: "#9B8BFF")], + static let brandHorizontal = LinearGradient( + colors: [Color.zxGradientStart, Color.zxGradientEnd], startPoint: .leading, endPoint: .trailing ) - // ── 思考卡片 ── - static let thinkingCard = LinearGradient( - colors: [ - Color(hex: "#7C6EFA", opacity: 0.08), - Color(hex: "#F97316", opacity: 0.04) - ], + static let ctaButton = LinearGradient( + colors: [Color.zxGradientStart, Color.zxGradientEnd], startPoint: .topLeading, endPoint: .bottomTrailing ) - // ── 进度卡片 ── - static let progressCard = LinearGradient( - colors: [ - Color(hex: "#7C6EFA", opacity: 0.10), - Color(hex: "#F97316", opacity: 0.05) - ], - startPoint: .topLeading, endPoint: .bottomTrailing - ) - - // ── 反馈评分卡片 ── - static let feedbackScore = LinearGradient( - colors: [ - Color(hex: "#7C6EFA", opacity: 0.12), - Color(hex: "#34D399", opacity: 0.06) - ], - startPoint: .topLeading, endPoint: .bottomTrailing - ) - - // ── Profile 卡片 ── - static let profileCard = LinearGradient( - colors: [ - Color(hex: "#7C6EFA", opacity: 0.15), - Color(hex: "#F97316", opacity: 0.08) - ], - startPoint: .topLeading, endPoint: .bottomTrailing - ) - - // ── Splash ── - static let splash = LinearGradient( - colors: [ - Color.zxBgSplash, - Color.zxBg0, - Color(light: Color(hex: "#F0F0F5"), dark: Color(hex: "#130D20")) - ], + static let page = LinearGradient( + colors: [Color.zxCanvas, Color.zxSurface], startPoint: .top, endPoint: .bottom ) - // ── 进度条 ── + static let heroGlow = RadialGradient( + colors: [Color.zxPrimarySoft, Color.clear], + center: .top, startRadius: 0, endRadius: 300 + ) + + static let thinkingCard = LinearGradient( + colors: [Color.zxPrimarySoft, Color.zxSurface], + startPoint: .topLeading, endPoint: .bottomTrailing + ) + static let progressBar = LinearGradient( - colors: [Color(hex: "#7C6EFA"), Color(hex: "#4ECDC4")], + colors: [Color.zxPrimary, Color.zxGradientEnd], startPoint: .leading, endPoint: .trailing ) - // ── CTA 按钮 ── - static let ctaButton = LinearGradient( - colors: [Color(hex: "#7C6EFA"), Color(hex: "#F97316")], - startPoint: .topLeading, endPoint: .bottomTrailing - ) - - static let ctaPurple = LinearGradient( - colors: [Color(hex: "#7C6EFA"), Color(hex: "#9B8BFF")], - startPoint: .topLeading, endPoint: .bottomTrailing - ) + // 兼容旧版 + static let brandPurple = brandHorizontal + static let feedbackScore = thinkingCard + static let profileCard = thinkingCard + static let progressCard = thinkingCard + static let splash = page + static let ctaPurple = brandHorizontal } // MARK: - ZhiXi Radii enum ZXRadius { - static let xs: CGFloat = 2 - static let sm: CGFloat = 8 - static let md: CGFloat = 10 - static let lg: CGFloat = 12 - static let xl: CGFloat = 14 - static let xl2: CGFloat = 16 - static let xl3: CGFloat = 20 - static let button: CGFloat = 12 - static let buttonLg: CGFloat = 18 - static let icon: CGFloat = 10 - static let iconLg: CGFloat = 12 - static let avatar: CGFloat = 13 + static let xs: CGFloat = 6 + static let sm: CGFloat = 8 + static let md: CGFloat = 10 + static let lg: CGFloat = 14 + static let xl: CGFloat = 20 + + // 兼容旧版 + static let xl2: CGFloat = 16 + static let xl3: CGFloat = 20 + static let button: CGFloat = 10 + static let buttonLg: CGFloat = 14 + static let icon: CGFloat = 8 + static let iconLg: CGFloat = 10 + static let avatar: CGFloat = 14 } // MARK: - ZhiXi Spacing enum ZXSpacing { - static let ss: CGFloat = 2 static let xs: CGFloat = 4 - static let sm: CGFloat = 6 - static let md: CGFloat = 8 - static let lg: CGFloat = 10 - static let xl: CGFloat = 12 - static let xl2: CGFloat = 14 - static let xl3: CGFloat = 16 - static let xl4: CGFloat = 20 - static let xl5: CGFloat = 24 - static let xl6: CGFloat = 28 + static let sm: CGFloat = 8 + static let md: CGFloat = 12 + static let lg: CGFloat = 16 + static let xl: CGFloat = 20 + static let xxl: CGFloat = 24 + static let xxxl: CGFloat = 32 + static let section: CGFloat = 40 static let pageHPadding: CGFloat = 20 + static let cardPadding: CGFloat = 16 static let statusBarH: CGFloat = 44 static let tabBarH: CGFloat = 83 static let homeIndicatorH: CGFloat = 34 + + // 兼容旧版 + static let ss: CGFloat = 2 + static let xl2: CGFloat = 14 + static let xl3: CGFloat = 16 + static let xl4: CGFloat = 20 + static let xl5: CGFloat = 24 + static let xl6: CGFloat = 28 } // MARK: - ZhiXi Sizing @@ -228,12 +227,12 @@ enum ZXSize { static let listIcon: CGFloat = 40 static let libraryIcon: CGFloat = 44 static let quickActionH: CGFloat = 72 - static let inputH: CGFloat = 44 - static let buttonH: CGFloat = 42 + static let inputH: CGFloat = 50 + static let buttonH: CGFloat = 50 static let buttonLgH: CGFloat = 52 static let buttonXlH: CGFloat = 56 - static let progressH: CGFloat = 5 - static let searchIconBtn: CGFloat = 36 + static let progressH: CGFloat = 6 + static let badgeSize: CGFloat = 28 static let avatarSm: CGFloat = 36 static let avatarMd: CGFloat = 64 static let avatarLg: CGFloat = 80 @@ -241,37 +240,33 @@ enum ZXSize { static let scoreBox: CGFloat = 36 static let weakBox: CGFloat = 40 static let topBar: CGFloat = 3 + static let searchIconBtn: CGFloat = 36 } -// MARK: - ZhiXi Typography (exact match from React) +// MARK: - ZhiXi Typography enum ZXFont { - // titleLarge: 22, heavy, -0.5 - static let titleLarge = (size: CGFloat(22), weight: Font.Weight.heavy, spacing: CGFloat(-0.5)) - // titleMedium: 20, heavy, -0.4 - static let titleMedium = (size: CGFloat(20), weight: Font.Weight.heavy, spacing: CGFloat(-0.4)) - // sectionTitle: 15, bold - static let sectionTitle = (size: CGFloat(15), weight: Font.Weight.bold) - // sectionTitle14: 14, bold - static let subsectionTitle = (size: CGFloat(14), weight: Font.Weight.bold) - // body: 13, semibold, 1.4 - static let body = (size: CGFloat(13), weight: Font.Weight.semibold, lineHeight: CGFloat(1.4)) - // bodySmall: 12, medium - static let bodySmall = (size: CGFloat(12), weight: Font.Weight.medium) - // caption: 10, bold - static let caption = (size: CGFloat(10), weight: Font.Weight.bold) - // captionSmall: 10, regular - static let captionSmall = (size: CGFloat(10), weight: Font.Weight.regular) - // labelXs: 9 - static let labelXs = (size: CGFloat(9), weight: Font.Weight.regular) - // score: 12, heavy + static let hero = (size: CGFloat(34), weight: Font.Weight.bold, spacing: CGFloat(-0.5)) + static let title1 = (size: CGFloat(28), weight: Font.Weight.bold, spacing: CGFloat(-0.3)) + static let title2 = (size: CGFloat(22), weight: Font.Weight.semibold, spacing: CGFloat(-0.2)) + static let title3 = (size: CGFloat(20), weight: Font.Weight.semibold, spacing: CGFloat(-0.1)) + static let headline = (size: CGFloat(17), weight: Font.Weight.semibold, spacing: CGFloat(0)) + static let body = (size: CGFloat(17), weight: Font.Weight.regular, lineHeight: CGFloat(1.47)) + static let bodySmall = (size: CGFloat(15), weight: Font.Weight.regular, lineHeight: CGFloat(1.47)) + static let callout = (size: CGFloat(14), weight: Font.Weight.medium, spacing: CGFloat(0)) + static let caption = (size: CGFloat(13), weight: Font.Weight.regular) + static let caption2 = (size: CGFloat(11), weight: Font.Weight.medium, spacing: CGFloat(0.5)) static let score = (size: CGFloat(12), weight: Font.Weight.heavy) - // scoreLg: 22, heavy, 1 - static let scoreLarge = (size: CGFloat(22), weight: Font.Weight.heavy, lineHeight: CGFloat(1)) - // date: 12, medium + static let scoreLg = (size: CGFloat(22), weight: Font.Weight.heavy, lineHeight: CGFloat(1)) + + // 兼容旧版 + static let titleLarge = (size: CGFloat(22), weight: Font.Weight.bold, spacing: CGFloat(-0.5)) + static let titleMedium = (size: CGFloat(20), weight: Font.Weight.bold, spacing: CGFloat(-0.4)) + static let sectionTitle = (size: CGFloat(15), weight: Font.Weight.bold) + static let subsectionTitle = (size: CGFloat(14), weight: Font.Weight.bold) static let date = (size: CGFloat(12), weight: Font.Weight.medium) - // description: 12, regular, 0.4 static let description = (size: CGFloat(12), weight: Font.Weight.regular, spacing: CGFloat(0.4)) + static let labelXs = (size: CGFloat(9), weight: Font.Weight.regular) } // MARK: - Dynamic Type Scaled Font diff --git a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift index 00bdc46..076cdc4 100644 --- a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift +++ b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift @@ -91,6 +91,7 @@ struct AuthUser: Codable, Identifiable { struct AppleAuthRequest: Codable { let identityToken: String let fullName: AppleFullName? + let nonce: String? struct AppleFullName: Codable { let givenName: String? diff --git a/AIStudyApp/AIStudyApp/Core/Network/APIClient.swift b/AIStudyApp/AIStudyApp/Core/Network/APIClient.swift index 9f02b74..817f59c 100644 --- a/AIStudyApp/AIStudyApp/Core/Network/APIClient.swift +++ b/AIStudyApp/AIStudyApp/Core/Network/APIClient.swift @@ -70,9 +70,9 @@ actor APIClient { return try await performRequest(path, method: method, body: body, queryItems: queryItems, isRetry: true) } await notifyTokenExpired() - throw APIError.unauthorized + throw decodeServerError(data, fallback: APIError.unauthorized) case 401: - throw APIError.unauthorized + throw decodeServerError(data, fallback: APIError.unauthorized) case 400..<500: let msg = String(data: data, encoding: .utf8) ?? "" throw APIError.serverError(msg) @@ -105,6 +105,27 @@ actor APIClient { } } + // MARK: - Server error decoding + + /// 从服务端错误响应中提取 message,避免丢弃真正的错误原因 + private func decodeServerError(_ data: Data, fallback: APIError) -> APIError { + if let serverMsg = extractServerMessage(data) { + return APIError.serverError(serverMsg) + } + return fallback + } + + private func extractServerMessage(_ data: Data) -> String? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + // NestJS GlobalExceptionFilter 格式: { success, statusCode, message } + if let msg = json["message"] as? String, !msg.isEmpty { + return msg + } + return nil + } + // MARK: - Decoding private func decodeResponse(_ data: Data) throws -> T { diff --git a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift index 31b83c3..ad2f128 100644 --- a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift +++ b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift @@ -30,12 +30,13 @@ class AuthService { static let shared = AuthService() private let client = APIClient.shared - func appleLogin(identityToken: String, givenName: String?, familyName: String?) async throws -> AuthResponse { + func appleLogin(identityToken: String, givenName: String?, familyName: String?, nonce: String? = nil) async throws -> AuthResponse { let body = AppleAuthRequest( identityToken: identityToken, fullName: givenName != nil ? AppleAuthRequest.AppleFullName(givenName: givenName, familyName: familyName) - : nil + : nil, + nonce: nonce ) return try await client.request("/auth/apple", method: "POST", body: body) } diff --git a/AIStudyApp/AIStudyApp/Features/AI/AIHomeView.swift b/AIStudyApp/AIStudyApp/Features/AI/AIHomeView.swift index a2ec696..76ce5ca 100644 --- a/AIStudyApp/AIStudyApp/Features/AI/AIHomeView.swift +++ b/AIStudyApp/AIStudyApp/Features/AI/AIHomeView.swift @@ -1,9 +1,49 @@ // -// AIHomeView.swift - Page 6: AI Home + API status indicator +// AIHomeView.swift — 知习 AI v2.0 设计 +// 主色 #3D7FFB, 渐变 #3D7FFB → #9DA7FD, 暖白底色 // import SwiftUI +// MARK: - Rive Animation Placeholder + +/// Rive 动画占位组件 — 后续替换为真实 Rive 动画 +struct ZXRivePlaceholder: View { + let height: CGFloat + let label: String + let icon: String + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: ZXRadius.lg) + .fill( + LinearGradient( + colors: [Color.zxPrimarySoft, Color.zxPrimarySoft.opacity(0.3)], + startPoint: .top, endPoint: .bottom + ) + ) + .overlay( + RoundedRectangle(cornerRadius: ZXRadius.lg) + .stroke(Color.zxPrimary.opacity(0.12), lineWidth: 1) + ) + + VStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 36)) + .foregroundColor(Color.zxPrimary.opacity(0.4)) + .symbolEffect(.pulse, options: .repeating.speed(0.5)) + + Text(label) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Color.zxInkSecondary) + } + } + .frame(height: height) + } +} + +// MARK: - AI Home View + struct AIHomeView: View { @State private var text = "" @State private var serverStatus: ServerStatus = .checking @@ -14,50 +54,48 @@ struct AIHomeView: View { var body: some View { ZStack { - ZXGradient.page.ignoresSafeArea() - Circle().fill(RadialGradient(colors:[Color(hex:"#7C6EFA",opacity:0.1),.clear],center:.top,startRadius:0,endRadius:200)) - .frame(width:200,height:200).offset(x:80,y:-80).allowsHitTesting(false) + // 页面背景 + Color.zxCanvas.ignoresSafeArea() - VStack(spacing:0){ - HStack(alignment:.bottom){ - VStack(alignment:.leading,spacing:2){ - Text("今天").font(.system(size:12,weight:.medium)).foregroundColor(Color.zxF04) - Text("AI 学习助手").font(.system(size:20,weight:.heavy)).foregroundColor(Color.zxF0).tracking(-0.4) - } - Spacer() + // 顶部柔光 + Circle() + .fill(RadialGradient( + colors: [Color.zxPrimarySoft.opacity(0.6), .clear], + center: .top, startRadius: 0, endRadius: 280 + )) + .frame(width: 280, height: 280) + .offset(y: -60) + .allowsHitTesting(false) - HStack(spacing: 4) { - Circle() - .fill(serverStatus == .online ? Color.zxGreen - : serverStatus == .checking ? Color.zxYellow - : Color.zxRed) - .frame(width: 8, height: 8) - Text(serverStatus == .online ? serverMessage - : serverStatus == .checking ? "检测中…" - : "离线") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(serverStatus == .online ? Color.zxGreen - : serverStatus == .checking ? Color.zxYellow - : Color.zxF03) - } - .padding(.horizontal, 8).padding(.vertical, 4) - .background(Color.zxFill005).clipShape(Capsule()) - - ZXIconBtn(icon:"arrow.clockwise",size:44){ Task { await checkServer() } } - } - .padding(.horizontal,20).padding(.top,ZXSpacing.statusBarH+16).padding(.bottom,12) + VStack(spacing: 0) { + // MARK: - 顶部状态栏 + topBar + .padding(.horizontal, ZXSpacing.pageHPadding) + .padding(.top, ZXSpacing.statusBarH + 12) ScrollView { - VStack(spacing:16){ - thinkingCard - quickActions - recentSection + VStack(spacing: ZXSpacing.xxl) { + // MARK: - Hero 区 + heroSection + + // MARK: - Rive 动画占位 + riveSection + + // MARK: - 快速操作 + quickActionsSection + + // MARK: - 今日思考 + thinkingCardSection + + // MARK: - AI 建议 suggestionSection } - .padding(.horizontal,20).padding(.bottom,100) + .padding(.horizontal, ZXSpacing.pageHPadding) + .padding(.bottom, 120) } .scrollIndicators(.hidden) + // MARK: - 底部输入栏 inputBar } @@ -67,6 +105,8 @@ struct AIHomeView: View { .task { await checkServer() } } + // MARK: - Server Check + private func checkServer() async { serverStatus = .checking do { @@ -80,134 +120,363 @@ struct AIHomeView: View { } } - private var thinkingCard: some View { - VStack(alignment:.leading,spacing:12){ - HStack{ - Image(systemName:"sparkles").font(.system(size:14)).foregroundColor(.white) - .frame(width:28,height:28).background(ZXGradient.brandPurple).clipShape(RoundedRectangle(cornerRadius:8)) - Text("今日思考题").font(.system(size:12,weight:.bold)).foregroundColor(Color.zxAccent).tracking(0.5) - Spacer() - Text("待回答").font(.system(size:10,weight:.bold)).foregroundColor(Color(hex:"#FBA574")) - .padding(.horizontal,8).padding(.vertical,2).background(Color(hex:"#F97316",opacity:0.2)).clipShape(Capsule()) - } - Text("解释\"注意力机制\"在 Transformer 中的作用,不能使用搜索,用你自己的话说。") - .zxFontScaled(size:14,weight:.medium).foregroundColor(Color.zxF0).lineSpacing(4) - NavigationLink(value: Route.dailyThinking) { - Text("开始回答").font(.system(size:13,weight:.bold)).foregroundColor(.white) - .frame(maxWidth:.infinity).frame(height:42) - .background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius:12)) - } - .accessibilityLabel("开始回答今日思考题") - .accessibilityHint("用费曼方法解释注意力机制") - } - .padding(16).background(ZXGradient.thinkingCard) - .overlay(RoundedRectangle(cornerRadius:20).stroke(Color(hex:"#7C6EFA",opacity:0.1),lineWidth:1)) - .clipShape(RoundedRectangle(cornerRadius:20)) - } + // MARK: - Top Bar - private var quickActions: some View { - HStack(spacing:12){ - NavigationLink(value: Route.activeRecall) { - ZXQuickAction(icon:"brain.head.profile",label:"生成\n回忆测试") - }.foregroundColor(.primary) - NavigationLink(value: Route.weakPoints) { - ZXQuickAction(icon:"magnifyingglass",label:"分析\n薄弱点") - }.foregroundColor(.primary) - NavigationLink(value: Route.aiChat) { - ZXQuickAction(icon:"mic.fill",label:"费曼\n解释练习") - }.foregroundColor(.primary) - NavigationLink(value: Route.reviewCard) { - ZXQuickAction(icon:"calendar",label:"今日\n复习计划") - }.foregroundColor(.primary) - } - } + private var topBar: some View { + HStack { + // 日期和服务状态 + VStack(alignment: .leading, spacing: 4) { + Text(formattedDate) + .font(.system(size: ZXFont.caption2.size, weight: .medium)) + .foregroundColor(Color.zxInkTertiary) + .textCase(.uppercase) - private var recentSection: some View { - VStack(alignment:.leading,spacing:12){ - HStack{ - Text("最近 AI 互动").font(.system(size:14,weight:.bold)).foregroundColor(Color.zxF0) - Spacer();Text("全部").font(.system(size:12)).foregroundColor(Color.zxPurple) + HStack(spacing: 6) { + Circle() + .fill(serverStatus == .online ? Color.zxMint + : serverStatus == .checking ? Color.zxAmber + : Color.zxCoral) + .frame(width: 6, height: 6) + Text(serverStatusLabel) + .font(.system(size: ZXFont.caption.size, weight: .medium)) + .foregroundColor(serverStatus == .online ? Color.zxMintDeep + : serverStatus == .checking ? Color.zxAmberDeep + : Color.zxCoralDeep) + } } - ZXAIInteractionRow(tag:"费曼复习",bg:Color(hex:"#7C6EFA",opacity:0.15),fg:Color.zxPurple,icon:"mic.fill", - title:"解释量子纠缠的核心概念",time:"2小时前",score:82){ navigateToChat = true } - ZXAIInteractionRow(tag:"薄弱点",bg:Color(hex:"#F97316",opacity:0.15),fg:Color(hex:"#FBA574"),icon:"exclamationmark.triangle.fill", - title:"混淆了协方差和相关系数",time:"昨天",score:56){ navigateToChat = true } - ZXAIInteractionRow(tag:"回忆测试",bg:Color(hex:"#7C6EFA",opacity:0.15),fg:Color.zxAccent,icon:"doc.text.fill", - title:"机器学习中的偏差-方差权衡",time:"2天前",score:91){ navigateToChat = true } - } - } - private var suggestionSection: some View { - VStack(alignment:.leading,spacing:10){ - (Text(Image(systemName:"lightbulb.fill")).foregroundColor(Color.zxYellow) + Text(" 你可以问 AI")).font(.system(size:12,weight:.semibold)).foregroundColor(Color.zxF04) - ForEach(["\"帮我测试机器学习这章的掌握情况\"","\"我最近的薄弱知识点有哪些?\"","\"生成一份本周的复习计划\""],id:\.self){s in - Button { text = s; navigateToChat = true } label: { - HStack{ - Text(s).zxFontScaled(size:12).foregroundColor(Color(hex:"#F0F0FF",opacity:0.55)).lineSpacing(4) - Spacer() - Image(systemName:"arrow.up").font(.system(size:12)).foregroundColor(Color(hex:"#7C6EFA",opacity:0.5)) - } - .padding(.horizontal,12).padding(.vertical,8) - .background(Color(hex:"#7C6EFA",opacity:0.06)).clipShape(RoundedRectangle(cornerRadius:10)) - }.foregroundColor(.primary) + Spacer() + + // 刷新按钮 + Button { Task { await checkServer() } } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(Color.zxInkSecondary) + .frame(width: 40, height: 40) + .background(Color.zxSurface) + .clipShape(RoundedRectangle(cornerRadius: ZXRadius.md)) } } - .padding(14).padding(.horizontal,2) - .background(Color(hex:"#FFFFFF",opacity:0.02)) - .overlay(RoundedRectangle(cornerRadius:16).stroke(Color(hex:"#FFFFFF",opacity:0.04),lineWidth:1)) - .clipShape(RoundedRectangle(cornerRadius:16)) } - private var inputBar: some View { - ZXAIInputBar(text: $text, onSend: { navigateToChat = true }) - .padding(.horizontal, 20) - .padding(.bottom, ZXSpacing.tabBarH + 20) - } -} + // MARK: - Hero Section -struct ZXQuickAction: View { - let icon: String - let label: String + private var heroSection: some View { + VStack(alignment: .leading, spacing: 10) { + // Tag + HStack(spacing: 6) { + Image(systemName: "sparkles") + .font(.system(size: 11)) + Text("AI 驱动学习") + .font(.system(size: ZXFont.caption2.size, weight: .semibold)) + } + .foregroundColor(Color.zxPrimary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.zxPrimarySoft) + .clipShape(RoundedRectangle(cornerRadius: ZXRadius.xs)) - var body: some View { - VStack(spacing:6){ - Image(systemName:icon).font(.system(size:22)).foregroundColor(Color.zxPurple) - Text(label).font(.system(size:10,weight:.medium)).foregroundColor(Color.zxF03) - .multilineTextAlignment(.center).lineSpacing(2) + // 主标题 + Text("用 AI 重新定义\n你的学习方式") + .font(.system( + size: ZXFont.hero.size, + weight: ZXFont.hero.weight + )) + .tracking(ZXFont.hero.spacing) + .foregroundColor(Color.zxInkPrimary) + .lineSpacing(4) + + // 副标题 + Text("智能导入 · 主动回忆 · 间隔复习 · AI 诊断") + .font(.system(size: ZXFont.bodySmall.size, weight: .regular)) + .foregroundColor(Color.zxInkSecondary) + .padding(.top, 2) } - .frame(width:72,height:72) - .background(Color.zxFill005).clipShape(RoundedRectangle(cornerRadius:16)) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: - Rive Section + + private var riveSection: some View { + ZXRivePlaceholder( + height: 180, + label: "Rive 动画 — AI 学习示意", + icon: "sparkles" + ) + } + + // MARK: - Quick Actions + + private var quickActionsSection: some View { + VStack(alignment: .leading, spacing: ZXSpacing.md) { + Text("快速操作") + .font(.system(size: ZXFont.caption2.size, weight: .semibold)) + .foregroundColor(Color.zxInkTertiary) + .textCase(.uppercase) + .tracking(0.5) + + HStack(spacing: 12) { + NavigationLink(value: Route.activeRecall) { + quickActionItem( + icon: "brain.head.profile", + label: "生成\n回忆测试" + ) + } + .foregroundColor(.primary) + + NavigationLink(value: Route.weakPoints) { + quickActionItem( + icon: "exclamationmark.triangle", + label: "分析\n薄弱点" + ) + } + .foregroundColor(.primary) + + NavigationLink(value: Route.aiChat) { + quickActionItem( + icon: "mic.fill", + label: "费曼\n解释练习" + ) + } + .foregroundColor(.primary) + + NavigationLink(value: Route.reviewCard) { + quickActionItem( + icon: "calendar", + label: "今日\n复习计划" + ) + } + .foregroundColor(.primary) + } + } + } + + private func quickActionItem(icon: String, label: String) -> some View { + VStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 20, weight: .regular)) + .foregroundColor(Color.zxPrimary) + .frame(width: 42, height: 42) + .background(Color.zxPrimarySoft) + .clipShape(RoundedRectangle(cornerRadius: ZXRadius.md)) + + Text(label) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color.zxInkSecondary) + .multilineTextAlignment(.center) + .lineSpacing(3) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.zxSurfaceElevated) + .overlay( + RoundedRectangle(cornerRadius: ZXRadius.lg) + .stroke(Color.zxHairline, lineWidth: 0.5) + ) + .clipShape(RoundedRectangle(cornerRadius: ZXRadius.lg)) .accessibilityLabel(label.replacingOccurrences(of: "\n", with: "")) } -} -struct ZXAIInteractionRow: View { - let tag: String - let bg: Color - let fg: Color - let icon: String - let title: String - let time: String - let score: Int - let action: () -> Void + // MARK: - Thinking Card - var body: some View { - Button(action: action) { - HStack(spacing:12){ - Image(systemName:icon).font(.system(size:16)).foregroundColor(fg) - .frame(width:36,height:36).background(bg).clipShape(RoundedRectangle(cornerRadius:10)) - VStack(alignment:.leading,spacing:4){ - HStack{ - Text(tag).font(.system(size:10,weight:.bold)).foregroundColor(fg) - Text(time).font(.system(size:10)).foregroundColor(Color.zxF04) + private var thinkingCardSection: some View { + VStack(alignment: .leading, spacing: ZXSpacing.md) { + Text("今日思考") + .font(.system(size: ZXFont.caption2.size, weight: .semibold)) + .foregroundColor(Color.zxInkTertiary) + .textCase(.uppercase) + .tracking(0.5) + + VStack(alignment: .leading, spacing: 14) { + // Header + 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: ZXFont.callout.size, 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)) + } + + // Question + Text("解释「注意力机制」在 Transformer 中的作用,不能使用搜索,用你自己的话说。") + .font(.system(size: ZXFont.bodySmall.size, weight: .regular)) + .foregroundColor(Color.zxInkPrimary) + .lineSpacing(6) + + // Action + NavigationLink(value: Route.dailyThinking) { + HStack(spacing: 8) { + Text("开始回答") + .font(.system(size: ZXFont.callout.size, weight: .semibold)) + Image(systemName: "arrow.right") + .font(.system(size: 12, weight: .semibold)) } - Text(title).font(.system(size:13,weight:.medium)).foregroundColor(Color.zxF0) - }.frame(maxWidth:.infinity,alignment:.leading) - Text("\(score)").font(.system(size:12,weight:.heavy)).foregroundColor(Color.zxYellow) - .frame(width:28,height:28).background(Color.zxYellowBG(0.1)).clipShape(RoundedRectangle(cornerRadius:8)) + .foregroundColor(Color.zxOnPrimary) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background(ZXGradient.brand) + .clipShape(RoundedRectangle(cornerRadius: ZXRadius.md)) + } + .accessibilityLabel("开始回答今日思考题") + .accessibilityHint("用费曼方法解释注意力机制") } - .padding(.horizontal,14).padding(.vertical,12) - .background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius:14)) + .padding(ZXSpacing.lg) + .background(Color.zxSurfaceElevated) + .overlay( + RoundedRectangle(cornerRadius: ZXRadius.lg) + .stroke(Color.zxHairline, lineWidth: 0.5) + ) + .clipShape(RoundedRectangle(cornerRadius: ZXRadius.lg)) + } + } + + // MARK: - Suggestions + + private var suggestionSection: some View { + VStack(alignment: .leading, spacing: ZXSpacing.md) { + HStack { + Text("你可以问 AI") + .font(.system(size: ZXFont.caption2.size, weight: .semibold)) + .foregroundColor(Color.zxInkTertiary) + .textCase(.uppercase) + .tracking(0.5) + Spacer() + Text("查看全部") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Color.zxPrimary) + } + + VStack(spacing: 8) { + suggestionRow("\"帮我测试机器学习这章的掌握情况\"", icon: "doc.text") + suggestionRow("\"我最近的薄弱知识点有哪些?\"", icon: "chart.bar") + suggestionRow("\"生成一份本周的复习计划\"", icon: "calendar.badge.plus") + } + } + } + + private func suggestionRow(_ text: String, icon: String) -> some View { + Button { + self.text = text + navigateToChat = true + } label: { + HStack(spacing: 10) { + Image(systemName: icon) + .font(.system(size: 13)) + .foregroundColor(Color.zxInkTertiary) + .frame(width: 32, height: 32) + .background(Color.zxSurface) + .clipShape(RoundedRectangle(cornerRadius: ZXRadius.sm)) + + Text(text) + .font(.system(size: ZXFont.bodySmall.size, weight: .regular)) + .foregroundColor(Color.zxInkPrimary) + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(Color.zxInkTertiary) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(Color.zxSurfaceElevated) + .overlay( + RoundedRectangle(cornerRadius: ZXRadius.lg) + .stroke(Color.zxHairline, lineWidth: 0.5) + ) + .clipShape(RoundedRectangle(cornerRadius: ZXRadius.lg)) + } + } + + // MARK: - Bottom Input Bar + + private var inputBar: some View { + HStack(spacing: 10) { + Image(systemName: "sparkles") + .font(.system(size: 15)) + .foregroundColor(Color.zxPrimary) + + TextField("问 AI 任何学习问题…", text: $text) + .font(.system(size: ZXFont.bodySmall.size)) + .tint(Color.zxPrimary) + + Spacer() + + // 语音按钮 — 占位 + Button {} label: { + Image(systemName: "mic.fill") + .font(.system(size: 17)) + .foregroundColor(Color.zxInkTertiary) + } + + // 发送按钮 + Button { + navigateToChat = true + } label: { + Image(systemName: "arrow.up") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(Color.zxOnPrimary) + .frame(width: 32, height: 32) + .background( + text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? AnyView(Color.zxHairlineStrong) + : AnyView(ZXGradient.brand) + ) + .clipShape(RoundedRectangle(cornerRadius: ZXRadius.sm)) + } + .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .accessibilityLabel("发送消息") + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.ultraThinMaterial) + .background(Color.zxSurfaceElevated) + .overlay( + RoundedRectangle(cornerRadius: ZXRadius.md) + .stroke(Color.zxHairline, lineWidth: 0.5) + ) + .clipShape(RoundedRectangle(cornerRadius: ZXRadius.md)) + .shadow(color: .black.opacity(0.04), radius: 8, y: -2) + .padding(.horizontal, ZXSpacing.pageHPadding) + .padding(.bottom, ZXSpacing.tabBarH + ZXSpacing.lg) + } + + // MARK: - Helpers + + private var formattedDate: String { + let f = DateFormatter() + f.locale = Locale(identifier: "zh_CN") + f.dateFormat = "M 月 d 日 EEEE" + return f.string(from: Date()) + } + + private var serverStatusLabel: String { + switch serverStatus { + case .online: return "服务在线" + case .checking: return "检测中…" + case .offline: return "离线" } } } + +// MARK: - Navigation stub (unchanged) + +#Preview { + AIHomeView() + .preferredColorScheme(.light) +}