- AI 综合分析卡片基于 activity 数据生成摘要文案 - 展示学习分钟/连续天数/掌握度等关键指标 - 下方列出 AI 学习推荐 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
202 lines
14 KiB
Swift
202 lines
14 KiB
Swift
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 }
|
||
}
|
||
}
|