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:
parent
abf2cb8efa
commit
0f8e542b2a
@ -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)
|
||||
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.red.opacity(0.1))
|
||||
.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,9 +182,18 @@ struct LoginPage: View {
|
||||
.disabled(isLoggingIn)
|
||||
.overlay {
|
||||
if isLoggingIn {
|
||||
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.5))
|
||||
.background(Color.black.opacity(0.6))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
}
|
||||
@ -186,13 +201,54 @@ struct LoginPage: View {
|
||||
} }
|
||||
}
|
||||
|
||||
// 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 {
|
||||
case .success(let auth):
|
||||
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 {
|
||||
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)) } } }
|
||||
|
||||
@ -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 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 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 zxGreen = Color(hex: "#34D399")
|
||||
static let zxYellow = Color(hex: "#F59E0B")
|
||||
static let zxRed = Color(hex: "#EF4444")
|
||||
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 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 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 xs: CGFloat = 6
|
||||
static let sm: CGFloat = 8
|
||||
static let md: CGFloat = 10
|
||||
static let lg: CGFloat = 12
|
||||
static let xl: CGFloat = 14
|
||||
static let lg: CGFloat = 14
|
||||
static let xl: CGFloat = 20
|
||||
|
||||
// 兼容旧版
|
||||
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 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 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
|
||||
|
||||
static let pageHPadding: CGFloat = 20
|
||||
static let statusBarH: CGFloat = 44
|
||||
static let tabBarH: CGFloat = 83
|
||||
static let homeIndicatorH: CGFloat = 34
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
// 顶部柔光
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
// 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){
|
||||
// MARK: - Top Bar
|
||||
|
||||
private var topBar: some View {
|
||||
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())
|
||||
// 日期和服务状态
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(formattedDate)
|
||||
.font(.system(size: ZXFont.caption2.size, weight: .medium))
|
||||
.foregroundColor(Color.zxInkTertiary)
|
||||
.textCase(.uppercase)
|
||||
|
||||
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)
|
||||
}
|
||||
Text("解释\"注意力机制\"在 Transformer 中的作用,不能使用搜索,用你自己的话说。")
|
||||
.zxFontScaled(size:14,weight:.medium).foregroundColor(Color.zxF0).lineSpacing(4)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hero Section
|
||||
|
||||
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))
|
||||
|
||||
// 主标题
|
||||
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(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: ""))
|
||||
}
|
||||
|
||||
// MARK: - Thinking Card
|
||||
|
||||
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) {
|
||||
Text("开始回答").font(.system(size:13,weight:.bold)).foregroundColor(.white)
|
||||
.frame(maxWidth:.infinity).frame(height:42)
|
||||
.background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius:12))
|
||||
HStack(spacing: 8) {
|
||||
Text("开始回答")
|
||||
.font(.system(size: ZXFont.callout.size, 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("开始回答今日思考题")
|
||||
.accessibilityHint("用费曼方法解释注意力机制")
|
||||
}
|
||||
.padding(16).background(ZXGradient.thinkingCard)
|
||||
.overlay(RoundedRectangle(cornerRadius:20).stroke(Color(hex:"#7C6EFA",opacity:0.1),lineWidth:1))
|
||||
.clipShape(RoundedRectangle(cornerRadius:20))
|
||||
}
|
||||
|
||||
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)
|
||||
.padding(ZXSpacing.lg)
|
||||
.background(Color.zxSurfaceElevated)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: ZXRadius.lg)
|
||||
.stroke(Color.zxHairline, lineWidth: 0.5)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: ZXRadius.lg))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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 }
|
||||
}
|
||||
}
|
||||
// MARK: - Suggestions
|
||||
|
||||
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: {
|
||||
VStack(alignment: .leading, spacing: ZXSpacing.md) {
|
||||
HStack {
|
||||
Text(s).zxFontScaled(size:12).foregroundColor(Color(hex:"#F0F0FF",opacity:0.55)).lineSpacing(4)
|
||||
Text("你可以问 AI")
|
||||
.font(.system(size: ZXFont.caption2.size, weight: .semibold))
|
||||
.foregroundColor(Color.zxInkTertiary)
|
||||
.textCase(.uppercase)
|
||||
.tracking(0.5)
|
||||
Spacer()
|
||||
Image(systemName:"arrow.up").font(.system(size:12)).foregroundColor(Color(hex:"#7C6EFA",opacity:0.5))
|
||||
Text("查看全部")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(Color.zxPrimary)
|
||||
}
|
||||
.padding(.horizontal,12).padding(.vertical,8)
|
||||
.background(Color(hex:"#7C6EFA",opacity:0.06)).clipShape(RoundedRectangle(cornerRadius:10))
|
||||
}.foregroundColor(.primary)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
suggestionRow("\"帮我测试机器学习这章的掌握情况\"", icon: "doc.text")
|
||||
suggestionRow("\"我最近的薄弱知识点有哪些?\"", icon: "chart.bar")
|
||||
suggestionRow("\"生成一份本周的复习计划\"", icon: "calendar.badge.plus")
|
||||
}
|
||||
}
|
||||
.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 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 {
|
||||
ZXAIInputBar(text: $text, onSend: { navigateToChat = true })
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, ZXSpacing.tabBarH + 20)
|
||||
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 "离线"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ZXQuickAction: View {
|
||||
let icon: String
|
||||
let label: String
|
||||
// MARK: - Navigation stub (unchanged)
|
||||
|
||||
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)
|
||||
}
|
||||
.frame(width:72,height:72)
|
||||
.background(Color.zxFill005).clipShape(RoundedRectangle(cornerRadius:16))
|
||||
.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
|
||||
|
||||
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)
|
||||
}
|
||||
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))
|
||||
}
|
||||
.padding(.horizontal,14).padding(.vertical,12)
|
||||
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius:14))
|
||||
}
|
||||
}
|
||||
#Preview {
|
||||
AIHomeView()
|
||||
.preferredColorScheme(.light)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user