wangdl dd97470e04 feat(ios): IOS-M1-06 分析页增加 AI 综合分析卡片
- AI 综合分析卡片基于 activity 数据生成摘要文案
- 展示学习分钟/连续天数/掌握度等关键指标
- 下方列出 AI 学习推荐

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 19:53:32 +08:00

202 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
struct AnalysisHomeView: View {
@StateObject private var viewModel = ActivityViewModel()
var body: some View {
ZStack {
Color.zxCanvas.ignoresSafeArea()
VStack(spacing: 0) {
HStack {
Text("学习分析").font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxF0).tracking(-0.5)
Spacer()
HStack(spacing: 4) { Text("近 7 天").font(.system(size: 12)).foregroundColor(Color.zxF05); Image(systemName: "chevron.down").font(.system(size: 10)).foregroundColor(Color.zxF04) }
.padding(.horizontal, 12).padding(.vertical, 6).background(Color.zxFill005).clipShape(Capsule()).overlay(Capsule().stroke(Color.zxBorder008, lineWidth: 1))
}
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 12)
ScrollView {
VStack(spacing: 16) {
if viewModel.isLoading && viewModel.summary == nil {
VStack(spacing: 12) { ZXLoadingView(size: 36, lineWidth: 3); Text("加载中…").font(.system(size: 13)).foregroundColor(Color.zxF04) }
.frame(maxWidth: .infinity).padding(.top, 80)
}
HStack(spacing: 12) {
ZXStatBadge(icon: "trophy.fill", label: "综合掌握", value: "\(viewModel.summary?.dailyAverage ?? 0)%", trend: "", color: Color.zxPurple)
ZXStatBadge(icon: "bolt.fill", label: "总分钟", value: "\(viewModel.summary?.totalMinutes ?? 0)", trend: "", color: Color.zxOrange)
ZXStatBadge(icon: "exclamationmark.triangle.fill", label: "复习卡片", value: "\(viewModel.summary?.totalCardsReviewed ?? 0)", trend: "", color: Color.zxYellow)
ZXStatBadge(icon: "chart.line.uptrend.xyaxis", label: "活跃天", value: "\(viewModel.summary?.activeDays ?? 0)", trend: "", color: Color.zxGreen)
}
VStack(alignment: .leading, spacing: 16) {
HStack { Text("掌握度趋势").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0); Spacer(); Text("↑ +8% 本周").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxGreen) }
ZXChartView()
}.padding(16).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20))
VStack(alignment: .leading, spacing: 14) {
Text("本周学习活跃").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0)
ZXWeekBarChart()
HStack {
Text("总计 3.5 小时").font(.system(size: 11)).foregroundColor(Color.zxF03)
Spacer()
Text("日均 30 分钟").font(.system(size: 11)).foregroundColor(Color.zxF03)
}
}.padding(16).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20))
if let streak = viewModel.streak {
HStack(spacing: 12) {
ZXStatBadge(icon: "flame.fill", label: "连续学习", value: "\(streak.currentStreak ?? 0)", trend: "", color: Color.zxOrange)
ZXStatBadge(icon: "trophy.fill", label: "最长连续", value: "\(streak.longestStreak ?? 0)", trend: "", color: Color.zxAmber)
ZXStatBadge(icon: "calendar", label: "最后活跃", value: streak.lastActiveDate.flatMap { String($0.prefix(10)) } ?? "-", trend: "", color: Color.zxPrimary)
}
}
if !viewModel.recommendations.isEmpty {
VStack(alignment: .leading, spacing: 12) {
Text("学习推荐").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0)
ForEach(viewModel.recommendations.prefix(3)) { rec in
HStack(spacing: 10) {
Image(systemName: rec.type == "review" ? "arrow.triangle.2.circlepath" : "lightbulb.fill")
.font(.system(size: 14)).foregroundColor(Color.zxAccent)
.frame(width: 32, height: 32).background(Color.zxAccent.opacity(0.1)).clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 2) {
Text(rec.title ?? "").font(.system(size: 13, weight: .semibold)).foregroundColor(Color.zxF0)
if let desc = rec.description { Text(desc).font(.system(size: 11)).foregroundColor(Color.zxF04).lineLimit(1) }
}
Spacer()
if let p = rec.priority { Text(p).font(.system(size: 10, weight: .bold)).foregroundColor(p == "high" ? Color.zxCoral : Color.zxAmber).padding(.horizontal, 6).padding(.vertical, 2).background((p == "high" ? Color.zxCoral : Color.zxAmber).opacity(0.1)).clipShape(Capsule()) }
}.padding(10).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 12))
}
}.padding(16).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20))
}
VStack(alignment: .leading, spacing: 12) {
HStack { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 14)).foregroundColor(Color.zxYellow); Text("薄弱知识点").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0) }; Spacer(); NavigationLink(value: Route.weakPoints) { Text("全部 \(viewModel.focusItems.count)").font(.system(size: 12)).foregroundColor(Color.zxPurple) }.accessibilityLabel("查看全部薄弱知识点") }
ForEach(viewModel.focusItems.prefix(5)) { item in
ZXWeakRow(score: item.masteryScore ?? 0, topic: item.title, lib: item.knowledgeBaseId ?? "", priority: item.priority ?? "normal")
}
if viewModel.focusItems.isEmpty && !viewModel.isLoading {
Text("暂无薄弱知识点").font(.system(size: 13)).foregroundColor(Color.zxF03)
}
}
}.padding(.horizontal, 20).padding(.bottom, 120)
}
// AI trends + recommendations
if let summary = viewModel.summary, !viewModel.trends.isEmpty || !viewModel.recommendations.isEmpty {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) { Image(systemName: "brain.head.profile").font(.system(size: 14)).foregroundColor(Color.zxPurple); Text("AI 综合分析").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0) }
Text(aiAnalysisText).font(.system(size: 13)).foregroundColor(Color.zxF05).lineSpacing(4)
if !viewModel.recommendations.isEmpty {
VStack(spacing: 6) {
ForEach(viewModel.recommendations.prefix(2)) { r in
HStack(spacing: 8) {
Image(systemName: "lightbulb.fill").font(.system(size: 11)).foregroundColor(Color.zxAccent)
Text(r.title ?? "").font(.system(size: 12)).foregroundColor(Color.zxF0)
Spacer()
}.padding(10).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 10))
}
}
}
}.padding(16).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20))
}
}.padding(.horizontal, 20).padding(.bottom, 120)
}
.scrollIndicators(.hidden)
.zxPullToRefresh { await viewModel.refresh() }
}
}
.task { await viewModel.loadAll() }
.navigationDestination(for: Route.self) { $0.destination }
}
private var aiAnalysisText: String {
let s = viewModel.summary
let streak = viewModel.streak
var parts: [String] = []
if let min = s?.totalMinutes, min > 0 { parts.append("本周学习了 \(min) 分钟") }
if let d = streak?.currentStreak, d > 0 { parts.append("连续 \(d) 天坚持学习") }
if let r = s?.totalCardsReviewed, r > 0 { parts.append("复习了 \(r) 张卡片") }
if let avg = s?.dailyAverage, avg > 0 { parts.append("掌握度 \(avg)%") }
if parts.isEmpty { return "开始学习后,我会根据你的表现给出分析建议。" }
return parts.joined(separator: "") + "。继续保持!"
}
}
struct ZXStatBadge: View { let icon: String; let label: String; let value: String; let trend: String; let color: Color
var body: some View {
VStack(spacing: 3) {
Image(systemName: icon).font(.system(size: 14)).foregroundColor(color)
Text(value).font(.system(size: 16, weight: .heavy)).foregroundColor(Color.zxF0)
Text(label).font(.system(size: 9)).foregroundColor(Color.zxF04).multilineTextAlignment(.center)
}.frame(maxWidth: .infinity).frame(height: 72).padding(.vertical, 4).background(color.opacity(0.06)).overlay(RoundedRectangle(cornerRadius: 14).stroke(color.opacity(0.15), lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 14))
}
}
struct ZXChartView: View {
let data: [(String, CGFloat)] = [("", 0.62), ("", 0.65), ("", 0.71), ("", 0.68), ("", 0.75), ("", 0.79), ("", 0.78)]
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var showChart = false
var body: some View {
VStack(spacing: 0) {
GeometryReader { g in
ZStack(alignment: .topLeading) {
// Gradient fill under the line
Path { path in let w = g.size.width / 7
for (i, d) in data.enumerated() { let x = w * CGFloat(i) + w / 2; let y = (1 - d.1) * g.size.height
if i == 0 { path.move(to: CGPoint(x: x, y: y)) } else { path.addLine(to: CGPoint(x: x, y: y)) } }
path.addLine(to: CGPoint(x: g.size.width, y: g.size.height))
path.addLine(to: CGPoint(x: w / 2, y: g.size.height))
path.closeSubpath()
}
.fill(
LinearGradient(
colors: [Color.zxPurple.opacity(0.2), Color.zxPurple.opacity(0.0)],
startPoint: .top, endPoint: .bottom
)
)
.opacity(showChart ? 1 : 0)
.animation(reduceMotion ? nil : .easeOut(duration: 0.8).delay(0.3), value: showChart)
// Animated line
Path { path in let w = g.size.width / 7
for (i, d) in data.enumerated() { let x = w * CGFloat(i) + w / 2; let y = (1 - d.1) * g.size.height
if i == 0 { path.move(to: CGPoint(x: x, y: y)) } else { path.addLine(to: CGPoint(x: x, y: y)) } }
}
.trim(from: 0, to: showChart ? 1 : 0)
.stroke(Color.zxPurple, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
.animation(reduceMotion ? nil : .easeOut(duration: 1.0), value: showChart)
}
}.frame(height: 100)
HStack(spacing: 0) { ForEach(data, id: \.0) { d in Text(d.0).font(.system(size: 9)).foregroundColor(Color(hex: "#F0F0FF", opacity: 0.35)).frame(maxWidth: .infinity) } }
}
.onAppear { showChart = true }
.animation(reduceMotion ? nil : .default, value: showChart)
}
}
struct ZXWeekBarChart: View {
let data: [(String, CGFloat)] = [("", 0.3), ("", 0.5), ("", 0.7), ("", 0.45), ("", 0.8), ("", 0.9), ("", 0.6)]
@State private var show = false
var body: some View {
VStack(spacing: 8) {
HStack(alignment: .bottom, spacing: 8) {
ForEach(data, id: \.0) { d in
VStack(spacing: 6) {
RoundedRectangle(cornerRadius: 6)
.fill(
LinearGradient(
colors: [Color.zxPurple.opacity(0.8), Color.zxAccent.opacity(0.6)],
startPoint: .top, endPoint: .bottom
)
)
.frame(height: show ? d.1 * 80 : 4)
.animation(.spring(response: 0.6, dampingFraction: 0.7), value: show)
Text(d.0)
.font(.system(size: 10))
.foregroundColor(Color.zxF03)
}
.frame(maxWidth: .infinity)
}
}
}
.frame(height: 100)
.onAppear { show = true }
}
}