fix(ios): Apple登录nonce+401透传+DesignSystem v2.0+AIHomePage重设计

- LoginPage: 新增nonce生成(SHA256)CryptoKit、取消按钮、Task可取消
- APIClient: 401响应透传服务端error message(不再硬编码)
- DesignTokens: 主色切换#3D7FFB渐变#3D7FFB→#9DA7FD、补全15个兼容别名
- AIHomeView: 全新v2.0设计(暖白底色/知习蓝/34pt hero/Rive占位)
- AppleAuthRequest: 新增nonce字段
This commit is contained in:
wangdl 2026-05-27 20:23:01 +08:00
parent abf2cb8efa
commit 0f8e542b2a
6 changed files with 690 additions and 328 deletions

View File

@ -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<Void, Never>?
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<ASAuthorization, Error>) {
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)) } } }

View File

@ -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

View File

@ -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?

View File

@ -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<T: Decodable>(_ data: Data) throws -> T {

View File

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

View File

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