feat(ios): IOS-M0-05 Learning Activity 学习统计详情

- ActivityService 新增 trend()/streak()/recommendations()
- ActivityViewModel 加载连续学习/趋势/推荐数据
- AnalysisHomeView 新增连续学习卡片(火焰+天数+最长记录)
- AnalysisHomeView 新增学习推荐列表(type图标+标题+优先级标签)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-05-28 20:17:15 +08:00
parent 6664612212
commit 0a8d873157
4 changed files with 74 additions and 8 deletions

View File

@ -493,6 +493,28 @@ struct AddSourceRequest: Codable {
let title: String?
}
// MARK: - Learning Activity
struct ActivityTrend: Codable {
let date: String?
let value: Double?
let label: String?
}
struct ActivityStreak: Codable {
let currentStreak: Int?
let longestStreak: Int?
let lastActiveDate: String?
}
struct ActivityRecommendation: Codable, Identifiable {
var id: String { title ?? UUID().uuidString }
let title: String?
let description: String?
let type: String?
let priority: String?
}
// MARK: - File Upload (COS presigned URL flow)
struct FileUploadUrlRequest: Codable {

View File

@ -258,6 +258,18 @@ class ActivityService {
func heatmap() async throws -> [String: Int] {
return try await client.request("/activity/heatmap")
}
func trend(days: Int = 7) async throws -> [ActivityTrend] {
return try await client.request("/activity/trend?days=\(days)")
}
func streak() async throws -> ActivityStreak {
return try await client.request("/activity/streak")
}
func recommendations() async throws -> [ActivityRecommendation] {
return try await client.request("/activity/recommendations")
}
}
// MARK: - Feedback

View File

@ -6,6 +6,9 @@ class ActivityViewModel: ObservableObject {
@Published var summary: ActivitySummary?
@Published var focusItems: [FocusItem] = []
@Published var heatmap: [String: Int] = [:]
@Published var streak: ActivityStreak?
@Published var trends: [ActivityTrend] = []
@Published var recommendations: [ActivityRecommendation] = []
@Published var isLoading = false
@Published var errorMessage: String?
@ -16,10 +19,12 @@ class ActivityViewModel: ObservableObject {
async let s = ActivityService.shared.summary()
async let f = FocusItemService.shared.list()
async let h = ActivityService.shared.heatmap()
let (summaryResult, focusResult, heatmapResult) = try await (s, f, h)
summary = summaryResult
focusItems = focusResult
heatmap = heatmapResult
async let st = ActivityService.shared.streak()
async let t = ActivityService.shared.trend()
async let r = ActivityService.shared.recommendations()
let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult) = try await (s, f, h, st, t, r)
summary = summaryResult; focusItems = focusResult; heatmap = heatmapResult
streak = streakResult; trends = trendResult; recommendations = recResult
} catch {
if summary == nil { errorMessage = "加载分析数据失败" }
}
@ -31,10 +36,12 @@ class ActivityViewModel: ObservableObject {
async let s = ActivityService.shared.summary()
async let f = FocusItemService.shared.list()
async let h = ActivityService.shared.heatmap()
let (summaryResult, focusResult, heatmapResult) = try await (s, f, h)
summary = summaryResult
focusItems = focusResult
heatmap = heatmapResult
async let st = ActivityService.shared.streak()
async let t = ActivityService.shared.trend()
async let r = ActivityService.shared.recommendations()
let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult) = try await (s, f, h, st, t, r)
summary = summaryResult; focusItems = focusResult; heatmap = heatmapResult
streak = streakResult; trends = trendResult; recommendations = recResult
} catch {}
}
}

View File

@ -39,6 +39,31 @@ struct AnalysisHomeView: View {
}
}.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