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 {
|
struct LoginPage: View {
|
||||||
@EnvironmentObject var authManager: AuthManager
|
@EnvironmentObject var authManager: AuthManager
|
||||||
@State private var isLoggingIn = false
|
@State private var isLoggingIn = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
@State private var loginTask: Task<Void, Never>?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@ -159,14 +160,19 @@ struct LoginPage: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let error = errorMessage {
|
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)
|
.padding(.horizontal, 16).padding(.vertical, 10)
|
||||||
.background(Color.red.opacity(0.1))
|
.background(Color(hex: "#FEE2E2"))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
}
|
}
|
||||||
|
|
||||||
SignInWithAppleButton(.signIn) { request in
|
SignInWithAppleButton(.signIn) { request in
|
||||||
request.requestedScopes = [.fullName, .email]
|
request.requestedScopes = [.fullName, .email]
|
||||||
|
request.nonce = LoginPage.generateNonceHash()
|
||||||
} onCompletion: { result in
|
} onCompletion: { result in
|
||||||
handleAppleResult(result)
|
handleAppleResult(result)
|
||||||
}
|
}
|
||||||
@ -176,9 +182,18 @@ struct LoginPage: View {
|
|||||||
.disabled(isLoggingIn)
|
.disabled(isLoggingIn)
|
||||||
.overlay {
|
.overlay {
|
||||||
if isLoggingIn {
|
if isLoggingIn {
|
||||||
|
VStack(spacing: 8) {
|
||||||
ProgressView().tint(.white)
|
ProgressView().tint(.white)
|
||||||
|
Button("取消") {
|
||||||
|
loginTask?.cancel()
|
||||||
|
isLoggingIn = false
|
||||||
|
errorMessage = nil
|
||||||
|
}
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color.black.opacity(0.5))
|
.background(Color.black.opacity(0.6))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
.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>) {
|
private func handleAppleResult(_ result: Result<ASAuthorization, Error>) {
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let auth):
|
case .success(let auth):
|
||||||
guard let credential = auth.credential as? ASAuthorizationAppleIDCredential,
|
guard let credential = auth.credential as? ASAuthorizationAppleIDCredential,
|
||||||
let identityToken = credential.identityToken,
|
let identityToken = credential.identityToken,
|
||||||
let tokenStr = String(data: identityToken, encoding: .utf8) else {
|
let tokenStr = String(data: identityToken, encoding: .utf8) else {
|
||||||
errorMessage = "获取 Apple 身份信息失败"
|
withAnimation { errorMessage = "获取 Apple 身份信息失败" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let givenName = credential.fullName?.givenName
|
let givenName = credential.fullName?.givenName
|
||||||
@ -200,29 +256,48 @@ struct LoginPage: View {
|
|||||||
|
|
||||||
isLoggingIn = true
|
isLoggingIn = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
Task {
|
|
||||||
|
loginTask = Task {
|
||||||
do {
|
do {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
let rawNonce = LoginPage.currentRawNonce
|
||||||
|
LoginPage.currentRawNonce = nil // 用完即清,防重放
|
||||||
let resp = try await AuthService.shared.appleLogin(
|
let resp = try await AuthService.shared.appleLogin(
|
||||||
identityToken: tokenStr,
|
identityToken: tokenStr,
|
||||||
givenName: givenName,
|
givenName: givenName,
|
||||||
familyName: familyName
|
familyName: familyName,
|
||||||
|
nonce: rawNonce
|
||||||
)
|
)
|
||||||
|
try Task.checkCancellation()
|
||||||
await authManager.signIn(resp)
|
await authManager.signIn(resp)
|
||||||
isLoggingIn = false
|
await MainActor.run { isLoggingIn = false }
|
||||||
|
} catch is CancellationError {
|
||||||
|
await MainActor.run { isLoggingIn = false }
|
||||||
} catch {
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
isLoggingIn = false
|
isLoggingIn = false
|
||||||
errorMessage = "登录失败: \(error.localizedDescription)"
|
errorMessage = "登录失败: \(error.localizedDescription)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
if (error as NSError).code != ASAuthorizationError.canceled.rawValue {
|
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
|
// 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)) } } }
|
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
|
// DesignTokens.swift
|
||||||
// AIStudyApp
|
// AIStudyApp
|
||||||
//
|
//
|
||||||
// 1:1 像素级还原 React 原型的设计令牌
|
// 知习 AI Design System v2.0 — Token 定义
|
||||||
|
// 主色 #3D7FFB (知习蓝), 渐变 #3D7FFB → #9DA7FD
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@ -27,8 +28,6 @@ extension Color {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Adaptive Color Helper
|
|
||||||
|
|
||||||
extension Color {
|
extension Color {
|
||||||
init(light: Color, dark: Color) {
|
init(light: Color, dark: Color) {
|
||||||
#if canImport(UIKit)
|
#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 {
|
extension Color {
|
||||||
// ── 背景 ──
|
// ── 品牌主色 ──
|
||||||
static let zxBg0 = Color(light: Color(hex: "#F5F5FA"), dark: Color(hex: "#0F0F1A"))
|
static let zxPrimary = Color(hex: "#3D7FFB")
|
||||||
static let zxBg1 = Color(light: Color(hex: "#EAEAF2"), dark: Color(hex: "#12122A"))
|
static let zxPrimaryPressed = Color(hex: "#2B65D1")
|
||||||
static let zxBg2 = Color(light: Color(hex: "#E0E0E8"), dark: Color(hex: "#0A0A14"))
|
static let zxPrimaryDeep = Color(hex: "#1E4FBB")
|
||||||
static let zxBgSplash = Color(light: Color(hex: "#F0F0F8"), dark: Color(hex: "#0D0D20"))
|
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 zxInkPrimary = Color(light: Color(hex: "#1E1B18"), dark: Color(hex: "#FFFFFF"))
|
||||||
static let zxF05 = Color(light: Color(hex: "#475569"), dark: Color(hex: "#F0F0FF", opacity: 0.5))
|
static let zxInkSecondary = Color(light: Color(hex: "#6B6560"), dark: Color(hex: "#A1A1A6"))
|
||||||
static let zxF04 = Color(light: Color(hex: "#475569"), dark: Color(hex: "#F0F0FF", opacity: 0.4))
|
static let zxInkTertiary = Color(light: Color(hex: "#9F9993"), dark: Color(hex: "#636366"))
|
||||||
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 zxPurple = Color(hex: "#7C6EFA")
|
static let zxAmber = Color(hex: "#F59E0B")
|
||||||
static let zxOrange = Color(hex: "#F97316")
|
static let zxAmberSoft = Color(hex: "#FEF3C7")
|
||||||
static let zxAccent = Color(hex: "#A78BFA")
|
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 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 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 func zxPurpleBG(_ a: Double = 0.10) -> Color { zxPrimarySoft.opacity(a * 1.5) }
|
||||||
static let zxBorder006 = Color(light: Color(hex: "#000000", opacity: 0.10), dark: Color(hex: "#FFFFFF", opacity: 0.06))
|
static func zxYellowBG(_ a: Double = 0.10) -> Color { zxAmberSoft.opacity(a * 2.0) }
|
||||||
static let zxBorder004 = Color(light: Color(hex: "#000000", opacity: 0.08), dark: Color(hex: "#FFFFFF", opacity: 0.04))
|
static func zxGreenBG(_ a: Double = 0.10) -> Color { zxMintSoft.opacity(a * 1.5) }
|
||||||
static let zxBorder01 = Color(light: Color(hex: "#000000", opacity: 0.10), dark: Color(hex: "#FFFFFF", opacity: 0.10))
|
static func zxRedBG(_ a: Double = 0.15) -> Color { zxCoralSoft.opacity(a * 1.5) }
|
||||||
static let zxBorder015 = Color(light: Color(hex: "#000000", opacity: 0.15), dark: Color(hex: "#FFFFFF", opacity: 0.15))
|
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) }
|
||||||
// ── 半透明填充 ──
|
|
||||||
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) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - ZhiXi Gradients (exact match)
|
// MARK: - ZhiXi Gradients v2.0
|
||||||
|
|
||||||
enum ZXGradient {
|
enum ZXGradient {
|
||||||
// ── 页面背景 ──
|
|
||||||
static let page = LinearGradient(
|
|
||||||
colors: [Color.zxBg0, Color.zxBg1],
|
|
||||||
startPoint: .top, endPoint: .bottom
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── 品牌渐变 (135deg) ──
|
|
||||||
static let brand = LinearGradient(
|
static let brand = LinearGradient(
|
||||||
colors: [Color(hex: "#7C6EFA"), Color(hex: "#F97316")],
|
colors: [Color.zxGradientStart, Color.zxGradientEnd],
|
||||||
startPoint: .topLeading, endPoint: .bottomTrailing
|
startPoint: .topLeading, endPoint: .bottomTrailing
|
||||||
)
|
)
|
||||||
|
|
||||||
static let brandPurple = LinearGradient(
|
static let brandHorizontal = LinearGradient(
|
||||||
colors: [Color(hex: "#7C6EFA"), Color(hex: "#9B8BFF")],
|
colors: [Color.zxGradientStart, Color.zxGradientEnd],
|
||||||
startPoint: .leading, endPoint: .trailing
|
startPoint: .leading, endPoint: .trailing
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── 思考卡片 ──
|
static let ctaButton = LinearGradient(
|
||||||
static let thinkingCard = LinearGradient(
|
colors: [Color.zxGradientStart, Color.zxGradientEnd],
|
||||||
colors: [
|
|
||||||
Color(hex: "#7C6EFA", opacity: 0.08),
|
|
||||||
Color(hex: "#F97316", opacity: 0.04)
|
|
||||||
],
|
|
||||||
startPoint: .topLeading, endPoint: .bottomTrailing
|
startPoint: .topLeading, endPoint: .bottomTrailing
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── 进度卡片 ──
|
static let page = LinearGradient(
|
||||||
static let progressCard = LinearGradient(
|
colors: [Color.zxCanvas, Color.zxSurface],
|
||||||
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"))
|
|
||||||
],
|
|
||||||
startPoint: .top, endPoint: .bottom
|
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(
|
static let progressBar = LinearGradient(
|
||||||
colors: [Color(hex: "#7C6EFA"), Color(hex: "#4ECDC4")],
|
colors: [Color.zxPrimary, Color.zxGradientEnd],
|
||||||
startPoint: .leading, endPoint: .trailing
|
startPoint: .leading, endPoint: .trailing
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── CTA 按钮 ──
|
// 兼容旧版
|
||||||
static let ctaButton = LinearGradient(
|
static let brandPurple = brandHorizontal
|
||||||
colors: [Color(hex: "#7C6EFA"), Color(hex: "#F97316")],
|
static let feedbackScore = thinkingCard
|
||||||
startPoint: .topLeading, endPoint: .bottomTrailing
|
static let profileCard = thinkingCard
|
||||||
)
|
static let progressCard = thinkingCard
|
||||||
|
static let splash = page
|
||||||
static let ctaPurple = LinearGradient(
|
static let ctaPurple = brandHorizontal
|
||||||
colors: [Color(hex: "#7C6EFA"), Color(hex: "#9B8BFF")],
|
|
||||||
startPoint: .topLeading, endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - ZhiXi Radii
|
// MARK: - ZhiXi Radii
|
||||||
|
|
||||||
enum ZXRadius {
|
enum ZXRadius {
|
||||||
static let xs: CGFloat = 2
|
static let xs: CGFloat = 6
|
||||||
static let sm: CGFloat = 8
|
static let sm: CGFloat = 8
|
||||||
static let md: CGFloat = 10
|
static let md: CGFloat = 10
|
||||||
static let lg: CGFloat = 12
|
static let lg: CGFloat = 14
|
||||||
static let xl: CGFloat = 14
|
static let xl: CGFloat = 20
|
||||||
|
|
||||||
|
// 兼容旧版
|
||||||
static let xl2: CGFloat = 16
|
static let xl2: CGFloat = 16
|
||||||
static let xl3: CGFloat = 20
|
static let xl3: CGFloat = 20
|
||||||
static let button: CGFloat = 12
|
static let button: CGFloat = 10
|
||||||
static let buttonLg: CGFloat = 18
|
static let buttonLg: CGFloat = 14
|
||||||
static let icon: CGFloat = 10
|
static let icon: CGFloat = 8
|
||||||
static let iconLg: CGFloat = 12
|
static let iconLg: CGFloat = 10
|
||||||
static let avatar: CGFloat = 13
|
static let avatar: CGFloat = 14
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - ZhiXi Spacing
|
// MARK: - ZhiXi Spacing
|
||||||
|
|
||||||
enum ZXSpacing {
|
enum ZXSpacing {
|
||||||
static let ss: CGFloat = 2
|
|
||||||
static let xs: CGFloat = 4
|
static let xs: CGFloat = 4
|
||||||
static let sm: CGFloat = 6
|
static let sm: CGFloat = 8
|
||||||
static let md: CGFloat = 8
|
static let md: CGFloat = 12
|
||||||
static let lg: CGFloat = 10
|
static let lg: CGFloat = 16
|
||||||
static let xl: CGFloat = 12
|
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 xl2: CGFloat = 14
|
||||||
static let xl3: CGFloat = 16
|
static let xl3: CGFloat = 16
|
||||||
static let xl4: CGFloat = 20
|
static let xl4: CGFloat = 20
|
||||||
static let xl5: CGFloat = 24
|
static let xl5: CGFloat = 24
|
||||||
static let xl6: CGFloat = 28
|
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
|
// MARK: - ZhiXi Sizing
|
||||||
@ -228,12 +227,12 @@ enum ZXSize {
|
|||||||
static let listIcon: CGFloat = 40
|
static let listIcon: CGFloat = 40
|
||||||
static let libraryIcon: CGFloat = 44
|
static let libraryIcon: CGFloat = 44
|
||||||
static let quickActionH: CGFloat = 72
|
static let quickActionH: CGFloat = 72
|
||||||
static let inputH: CGFloat = 44
|
static let inputH: CGFloat = 50
|
||||||
static let buttonH: CGFloat = 42
|
static let buttonH: CGFloat = 50
|
||||||
static let buttonLgH: CGFloat = 52
|
static let buttonLgH: CGFloat = 52
|
||||||
static let buttonXlH: CGFloat = 56
|
static let buttonXlH: CGFloat = 56
|
||||||
static let progressH: CGFloat = 5
|
static let progressH: CGFloat = 6
|
||||||
static let searchIconBtn: CGFloat = 36
|
static let badgeSize: CGFloat = 28
|
||||||
static let avatarSm: CGFloat = 36
|
static let avatarSm: CGFloat = 36
|
||||||
static let avatarMd: CGFloat = 64
|
static let avatarMd: CGFloat = 64
|
||||||
static let avatarLg: CGFloat = 80
|
static let avatarLg: CGFloat = 80
|
||||||
@ -241,37 +240,33 @@ enum ZXSize {
|
|||||||
static let scoreBox: CGFloat = 36
|
static let scoreBox: CGFloat = 36
|
||||||
static let weakBox: CGFloat = 40
|
static let weakBox: CGFloat = 40
|
||||||
static let topBar: CGFloat = 3
|
static let topBar: CGFloat = 3
|
||||||
|
static let searchIconBtn: CGFloat = 36
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - ZhiXi Typography (exact match from React)
|
// MARK: - ZhiXi Typography
|
||||||
|
|
||||||
enum ZXFont {
|
enum ZXFont {
|
||||||
// titleLarge: 22, heavy, -0.5
|
static let hero = (size: CGFloat(34), weight: Font.Weight.bold, spacing: CGFloat(-0.5))
|
||||||
static let titleLarge = (size: CGFloat(22), weight: Font.Weight.heavy, spacing: CGFloat(-0.5))
|
static let title1 = (size: CGFloat(28), weight: Font.Weight.bold, spacing: CGFloat(-0.3))
|
||||||
// titleMedium: 20, heavy, -0.4
|
static let title2 = (size: CGFloat(22), weight: Font.Weight.semibold, spacing: CGFloat(-0.2))
|
||||||
static let titleMedium = (size: CGFloat(20), weight: Font.Weight.heavy, spacing: CGFloat(-0.4))
|
static let title3 = (size: CGFloat(20), weight: Font.Weight.semibold, spacing: CGFloat(-0.1))
|
||||||
// sectionTitle: 15, bold
|
static let headline = (size: CGFloat(17), weight: Font.Weight.semibold, spacing: CGFloat(0))
|
||||||
static let sectionTitle = (size: CGFloat(15), weight: Font.Weight.bold)
|
static let body = (size: CGFloat(17), weight: Font.Weight.regular, lineHeight: CGFloat(1.47))
|
||||||
// sectionTitle14: 14, bold
|
static let bodySmall = (size: CGFloat(15), weight: Font.Weight.regular, lineHeight: CGFloat(1.47))
|
||||||
static let subsectionTitle = (size: CGFloat(14), weight: Font.Weight.bold)
|
static let callout = (size: CGFloat(14), weight: Font.Weight.medium, spacing: CGFloat(0))
|
||||||
// body: 13, semibold, 1.4
|
static let caption = (size: CGFloat(13), weight: Font.Weight.regular)
|
||||||
static let body = (size: CGFloat(13), weight: Font.Weight.semibold, lineHeight: CGFloat(1.4))
|
static let caption2 = (size: CGFloat(11), weight: Font.Weight.medium, spacing: CGFloat(0.5))
|
||||||
// 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 score = (size: CGFloat(12), weight: Font.Weight.heavy)
|
static let score = (size: CGFloat(12), weight: Font.Weight.heavy)
|
||||||
// scoreLg: 22, heavy, 1
|
static let scoreLg = (size: CGFloat(22), weight: Font.Weight.heavy, lineHeight: CGFloat(1))
|
||||||
static let scoreLarge = (size: CGFloat(22), weight: Font.Weight.heavy, lineHeight: CGFloat(1))
|
|
||||||
// date: 12, medium
|
// 兼容旧版
|
||||||
|
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)
|
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 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
|
// MARK: - Dynamic Type Scaled Font
|
||||||
|
|||||||
@ -91,6 +91,7 @@ struct AuthUser: Codable, Identifiable {
|
|||||||
struct AppleAuthRequest: Codable {
|
struct AppleAuthRequest: Codable {
|
||||||
let identityToken: String
|
let identityToken: String
|
||||||
let fullName: AppleFullName?
|
let fullName: AppleFullName?
|
||||||
|
let nonce: String?
|
||||||
|
|
||||||
struct AppleFullName: Codable {
|
struct AppleFullName: Codable {
|
||||||
let givenName: String?
|
let givenName: String?
|
||||||
|
|||||||
@ -70,9 +70,9 @@ actor APIClient {
|
|||||||
return try await performRequest(path, method: method, body: body, queryItems: queryItems, isRetry: true)
|
return try await performRequest(path, method: method, body: body, queryItems: queryItems, isRetry: true)
|
||||||
}
|
}
|
||||||
await notifyTokenExpired()
|
await notifyTokenExpired()
|
||||||
throw APIError.unauthorized
|
throw decodeServerError(data, fallback: APIError.unauthorized)
|
||||||
case 401:
|
case 401:
|
||||||
throw APIError.unauthorized
|
throw decodeServerError(data, fallback: APIError.unauthorized)
|
||||||
case 400..<500:
|
case 400..<500:
|
||||||
let msg = String(data: data, encoding: .utf8) ?? ""
|
let msg = String(data: data, encoding: .utf8) ?? ""
|
||||||
throw APIError.serverError(msg)
|
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
|
// MARK: - Decoding
|
||||||
|
|
||||||
private func decodeResponse<T: Decodable>(_ data: Data) throws -> T {
|
private func decodeResponse<T: Decodable>(_ data: Data) throws -> T {
|
||||||
|
|||||||
@ -30,12 +30,13 @@ class AuthService {
|
|||||||
static let shared = AuthService()
|
static let shared = AuthService()
|
||||||
private let client = APIClient.shared
|
private let client = APIClient.shared
|
||||||
|
|
||||||
func appleLogin(identityToken: String, givenName: String?, familyName: String?) async throws -> AuthResponse {
|
func appleLogin(identityToken: String, givenName: String?, familyName: String?, nonce: String? = nil) async throws -> AuthResponse {
|
||||||
let body = AppleAuthRequest(
|
let body = AppleAuthRequest(
|
||||||
identityToken: identityToken,
|
identityToken: identityToken,
|
||||||
fullName: givenName != nil
|
fullName: givenName != nil
|
||||||
? AppleAuthRequest.AppleFullName(givenName: givenName, familyName: familyName)
|
? AppleAuthRequest.AppleFullName(givenName: givenName, familyName: familyName)
|
||||||
: nil
|
: nil,
|
||||||
|
nonce: nonce
|
||||||
)
|
)
|
||||||
return try await client.request("/auth/apple", method: "POST", body: body)
|
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
|
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 {
|
struct AIHomeView: View {
|
||||||
@State private var text = ""
|
@State private var text = ""
|
||||||
@State private var serverStatus: ServerStatus = .checking
|
@State private var serverStatus: ServerStatus = .checking
|
||||||
@ -14,50 +54,48 @@ struct AIHomeView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
ZXGradient.page.ignoresSafeArea()
|
// 页面背景
|
||||||
Circle().fill(RadialGradient(colors:[Color(hex:"#7C6EFA",opacity:0.1),.clear],center:.top,startRadius:0,endRadius:200))
|
Color.zxCanvas.ignoresSafeArea()
|
||||||
.frame(width:200,height:200).offset(x:80,y:-80).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()
|
Circle()
|
||||||
.fill(serverStatus == .online ? Color.zxGreen
|
.fill(RadialGradient(
|
||||||
: serverStatus == .checking ? Color.zxYellow
|
colors: [Color.zxPrimarySoft.opacity(0.6), .clear],
|
||||||
: Color.zxRed)
|
center: .top, startRadius: 0, endRadius: 280
|
||||||
.frame(width: 8, height: 8)
|
))
|
||||||
Text(serverStatus == .online ? serverMessage
|
.frame(width: 280, height: 280)
|
||||||
: serverStatus == .checking ? "检测中…"
|
.offset(y: -60)
|
||||||
: "离线")
|
.allowsHitTesting(false)
|
||||||
.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() } }
|
VStack(spacing: 0) {
|
||||||
}
|
// MARK: - 顶部状态栏
|
||||||
.padding(.horizontal,20).padding(.top,ZXSpacing.statusBarH+16).padding(.bottom,12)
|
topBar
|
||||||
|
.padding(.horizontal, ZXSpacing.pageHPadding)
|
||||||
|
.padding(.top, ZXSpacing.statusBarH + 12)
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing:16){
|
VStack(spacing: ZXSpacing.xxl) {
|
||||||
thinkingCard
|
// MARK: - Hero 区
|
||||||
quickActions
|
heroSection
|
||||||
recentSection
|
|
||||||
|
// MARK: - Rive 动画占位
|
||||||
|
riveSection
|
||||||
|
|
||||||
|
// MARK: - 快速操作
|
||||||
|
quickActionsSection
|
||||||
|
|
||||||
|
// MARK: - 今日思考
|
||||||
|
thinkingCardSection
|
||||||
|
|
||||||
|
// MARK: - AI 建议
|
||||||
suggestionSection
|
suggestionSection
|
||||||
}
|
}
|
||||||
.padding(.horizontal,20).padding(.bottom,100)
|
.padding(.horizontal, ZXSpacing.pageHPadding)
|
||||||
|
.padding(.bottom, 120)
|
||||||
}
|
}
|
||||||
.scrollIndicators(.hidden)
|
.scrollIndicators(.hidden)
|
||||||
|
|
||||||
|
// MARK: - 底部输入栏
|
||||||
inputBar
|
inputBar
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,6 +105,8 @@ struct AIHomeView: View {
|
|||||||
.task { await checkServer() }
|
.task { await checkServer() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Server Check
|
||||||
|
|
||||||
private func checkServer() async {
|
private func checkServer() async {
|
||||||
serverStatus = .checking
|
serverStatus = .checking
|
||||||
do {
|
do {
|
||||||
@ -80,134 +120,363 @@ struct AIHomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var thinkingCard: some View {
|
// MARK: - Top Bar
|
||||||
VStack(alignment:.leading,spacing:12){
|
|
||||||
HStack{
|
private var topBar: some View {
|
||||||
Image(systemName:"sparkles").font(.system(size:14)).foregroundColor(.white)
|
HStack {
|
||||||
.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)
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Spacer()
|
Text(formattedDate)
|
||||||
Text("待回答").font(.system(size:10,weight:.bold)).foregroundColor(Color(hex:"#FBA574"))
|
.font(.system(size: ZXFont.caption2.size, weight: .medium))
|
||||||
.padding(.horizontal,8).padding(.vertical,2).background(Color(hex:"#F97316",opacity:0.2)).clipShape(Capsule())
|
.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) {
|
NavigationLink(value: Route.dailyThinking) {
|
||||||
Text("开始回答").font(.system(size:13,weight:.bold)).foregroundColor(.white)
|
HStack(spacing: 8) {
|
||||||
.frame(maxWidth:.infinity).frame(height:42)
|
Text("开始回答")
|
||||||
.background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius:12))
|
.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("开始回答今日思考题")
|
.accessibilityLabel("开始回答今日思考题")
|
||||||
.accessibilityHint("用费曼方法解释注意力机制")
|
.accessibilityHint("用费曼方法解释注意力机制")
|
||||||
}
|
}
|
||||||
.padding(16).background(ZXGradient.thinkingCard)
|
.padding(ZXSpacing.lg)
|
||||||
.overlay(RoundedRectangle(cornerRadius:20).stroke(Color(hex:"#7C6EFA",opacity:0.1),lineWidth:1))
|
.background(Color.zxSurfaceElevated)
|
||||||
.clipShape(RoundedRectangle(cornerRadius:20))
|
.overlay(
|
||||||
}
|
RoundedRectangle(cornerRadius: ZXRadius.lg)
|
||||||
|
.stroke(Color.zxHairline, lineWidth: 0.5)
|
||||||
private var quickActions: some View {
|
)
|
||||||
HStack(spacing:12){
|
.clipShape(RoundedRectangle(cornerRadius: ZXRadius.lg))
|
||||||
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 recentSection: some View {
|
// MARK: - Suggestions
|
||||||
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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var suggestionSection: some View {
|
private var suggestionSection: some View {
|
||||||
VStack(alignment:.leading,spacing:10){
|
VStack(alignment: .leading, spacing: ZXSpacing.md) {
|
||||||
(Text(Image(systemName:"lightbulb.fill")).foregroundColor(Color.zxYellow) + Text(" 你可以问 AI")).font(.system(size:12,weight:.semibold)).foregroundColor(Color.zxF04)
|
HStack {
|
||||||
ForEach(["\"帮我测试机器学习这章的掌握情况\"","\"我最近的薄弱知识点有哪些?\"","\"生成一份本周的复习计划\""],id:\.self){s in
|
Text("你可以问 AI")
|
||||||
Button { text = s; navigateToChat = true } label: {
|
.font(.system(size: ZXFont.caption2.size, weight: .semibold))
|
||||||
HStack{
|
.foregroundColor(Color.zxInkTertiary)
|
||||||
Text(s).zxFontScaled(size:12).foregroundColor(Color(hex:"#F0F0FF",opacity:0.55)).lineSpacing(4)
|
.textCase(.uppercase)
|
||||||
|
.tracking(0.5)
|
||||||
Spacer()
|
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))
|
VStack(spacing: 8) {
|
||||||
}.foregroundColor(.primary)
|
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 {
|
private var inputBar: some View {
|
||||||
ZXAIInputBar(text: $text, onSend: { navigateToChat = true })
|
HStack(spacing: 10) {
|
||||||
.padding(.horizontal, 20)
|
Image(systemName: "sparkles")
|
||||||
.padding(.bottom, ZXSpacing.tabBarH + 20)
|
.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 {
|
// MARK: - Navigation stub (unchanged)
|
||||||
let icon: String
|
|
||||||
let label: String
|
|
||||||
|
|
||||||
var body: some View {
|
#Preview {
|
||||||
VStack(spacing:6){
|
AIHomeView()
|
||||||
Image(systemName:icon).font(.system(size:22)).foregroundColor(Color.zxPurple)
|
.preferredColorScheme(.light)
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user