- icon-play / icon-pause / icon-download / icon-upload - icon-chevron-left / icon-chevron-right - 全局替换 systemName: chevron/play 引用 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
483 lines
17 KiB
Swift
483 lines
17 KiB
Swift
//
|
|
// 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
|
|
@State private var serverMessage = ""
|
|
@State private var navigateToChat = false
|
|
|
|
enum ServerStatus { case checking, online, offline }
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
// 页面背景
|
|
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) {
|
|
// MARK: - 顶部状态栏
|
|
topBar
|
|
.padding(.horizontal, ZXSpacing.pageHPadding)
|
|
.padding(.top, ZXSpacing.statusBarH + 12)
|
|
|
|
ScrollView {
|
|
VStack(spacing: ZXSpacing.xxl) {
|
|
// MARK: - Hero 区
|
|
heroSection
|
|
|
|
// MARK: - Rive 动画占位
|
|
riveSection
|
|
|
|
// MARK: - 快速操作
|
|
quickActionsSection
|
|
|
|
// MARK: - 今日思考
|
|
thinkingCardSection
|
|
|
|
// MARK: - AI 建议
|
|
suggestionSection
|
|
}
|
|
.padding(.horizontal, ZXSpacing.pageHPadding)
|
|
.padding(.bottom, 120)
|
|
}
|
|
.scrollIndicators(.hidden)
|
|
|
|
// MARK: - 底部输入栏
|
|
inputBar
|
|
}
|
|
|
|
NavigationLink(destination: AIChatPage(), isActive: $navigateToChat) { EmptyView() }
|
|
}
|
|
.navigationDestination(for: Route.self) { $0.destination }
|
|
.task { await checkServer() }
|
|
}
|
|
|
|
// MARK: - Server Check
|
|
|
|
private func checkServer() async {
|
|
serverStatus = .checking
|
|
do {
|
|
struct HealthResponse: Decodable { let status: String }
|
|
let resp: HealthResponse = try await APIClient.shared.request("/")
|
|
serverStatus = .online
|
|
serverMessage = resp.status
|
|
} catch {
|
|
serverStatus = .offline
|
|
serverMessage = ""
|
|
}
|
|
}
|
|
|
|
// MARK: - Top Bar
|
|
|
|
private var topBar: some View {
|
|
HStack {
|
|
// 日期和服务状态
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(formattedDate)
|
|
.font(.system(size: ZXFont.caption2.size, weight: .medium))
|
|
.foregroundColor(Color.zxInkTertiary)
|
|
.textCase(.uppercase)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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(ZXSpacing.lg)
|
|
.background(Color.zxSurfaceElevated)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: ZXRadius.lg)
|
|
.stroke(Color.zxHairline, lineWidth: 0.5)
|
|
)
|
|
.clipShape(RoundedRectangle(cornerRadius: ZXRadius.lg))
|
|
}
|
|
}
|
|
|
|
// MARK: - Suggestions
|
|
|
|
private var suggestionSection: some View {
|
|
VStack(alignment: .leading, spacing: ZXSpacing.md) {
|
|
HStack {
|
|
Text("你可以问 AI")
|
|
.font(.system(size: ZXFont.caption2.size, weight: .semibold))
|
|
.foregroundColor(Color.zxInkTertiary)
|
|
.textCase(.uppercase)
|
|
.tracking(0.5)
|
|
Spacer()
|
|
Text("查看全部")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(Color.zxPrimary)
|
|
}
|
|
|
|
VStack(spacing: 8) {
|
|
suggestionRow("\"帮我测试机器学习这章的掌握情况\"", icon: "doc.text")
|
|
suggestionRow("\"我最近的薄弱知识点有哪些?\"", icon: "chart.bar")
|
|
suggestionRow("\"生成一份本周的复习计划\"", icon: "calendar.badge.plus")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func suggestionRow(_ text: String, icon: String) -> some View {
|
|
Button {
|
|
self.text = text
|
|
navigateToChat = true
|
|
} label: {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 13))
|
|
.foregroundColor(Color.zxInkTertiary)
|
|
.frame(width: 32, height: 32)
|
|
.background(Color.zxSurface)
|
|
.clipShape(RoundedRectangle(cornerRadius: ZXRadius.sm))
|
|
|
|
Text(text)
|
|
.font(.system(size: ZXFont.bodySmall.size, weight: .regular))
|
|
.foregroundColor(Color.zxInkPrimary)
|
|
|
|
Spacer()
|
|
|
|
Image("icon-chevron-right")
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundColor(Color.zxInkTertiary)
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 12)
|
|
.background(Color.zxSurfaceElevated)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: ZXRadius.lg)
|
|
.stroke(Color.zxHairline, lineWidth: 0.5)
|
|
)
|
|
.clipShape(RoundedRectangle(cornerRadius: ZXRadius.lg))
|
|
}
|
|
}
|
|
|
|
// MARK: - Bottom Input Bar
|
|
|
|
private var inputBar: some View {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: "sparkles")
|
|
.font(.system(size: 15))
|
|
.foregroundColor(Color.zxPrimary)
|
|
|
|
TextField("问 AI 任何学习问题…", text: $text)
|
|
.font(.system(size: ZXFont.bodySmall.size))
|
|
.tint(Color.zxPrimary)
|
|
|
|
Spacer()
|
|
|
|
// 语音按钮 — 占位
|
|
Button {} label: {
|
|
Image(systemName: "mic.fill")
|
|
.font(.system(size: 17))
|
|
.foregroundColor(Color.zxInkTertiary)
|
|
}
|
|
|
|
// 发送按钮
|
|
Button {
|
|
navigateToChat = true
|
|
} label: {
|
|
Image(systemName: "arrow.up")
|
|
.font(.system(size: 14, weight: .bold))
|
|
.foregroundColor(Color.zxOnPrimary)
|
|
.frame(width: 32, height: 32)
|
|
.background(
|
|
text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
? AnyView(Color.zxHairlineStrong)
|
|
: AnyView(ZXGradient.brand)
|
|
)
|
|
.clipShape(RoundedRectangle(cornerRadius: ZXRadius.sm))
|
|
}
|
|
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
.accessibilityLabel("发送消息")
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
.background(.ultraThinMaterial)
|
|
.background(Color.zxSurfaceElevated)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: ZXRadius.md)
|
|
.stroke(Color.zxHairline, lineWidth: 0.5)
|
|
)
|
|
.clipShape(RoundedRectangle(cornerRadius: ZXRadius.md))
|
|
.shadow(color: .black.opacity(0.04), radius: 8, y: -2)
|
|
.padding(.horizontal, ZXSpacing.pageHPadding)
|
|
.padding(.bottom, ZXSpacing.tabBarH + ZXSpacing.lg)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private var formattedDate: String {
|
|
let f = DateFormatter()
|
|
f.locale = Locale(identifier: "zh_CN")
|
|
f.dateFormat = "M 月 d 日 EEEE"
|
|
return f.string(from: Date())
|
|
}
|
|
|
|
private var serverStatusLabel: String {
|
|
switch serverStatus {
|
|
case .online: return "服务在线"
|
|
case .checking: return "检测中…"
|
|
case .offline: return "离线"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Navigation stub (unchanged)
|
|
|
|
#Preview {
|
|
AIHomeView()
|
|
.preferredColorScheme(.light)
|
|
}
|