feat: iOS 学习功能增强 - 阅读API、材料阅读器、分析、学习首页

- ReadingAPI: 阅读进度同步
- MaterialPathResolver: 材料路径解析
- MarkedReadStore: 已读标记存储
- MaterialReaderView/DetailView/ReadingPositionAdapter: 阅读器增强
- AnalysisHomeView/ActivityViewModel: 分析首页
- StudyHomeView/StudyHomeViewModel: 学习首页
- LibrarySubpages: 图书馆子页面

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-18 11:22:26 +08:00
parent 3abe17a84e
commit 82bcbb9efb
11 changed files with 310 additions and 33 deletions

View File

@ -0,0 +1,59 @@
import Foundation
/// Resolve a materialId + materialType to a local file path for the reader.
struct MaterialPathResolver {
/// Try to locate the file on disk given the continue-learning API response.
/// Returns (filePath, materialType) or nil if the file cannot be found.
static func resolve(materialId: String, apiType: String?, title: String?) -> (filePath: String, materialType: MaterialType)? {
let mt = materialTypeFrom(apiType: apiType, title: title)
// 1. knowledge_source: look up by source ID in local KnowledgeSourceService
if apiType == "knowledge_source" {
if let path = resolveKnowledgeSource(id: materialId) {
return (path, mt)
}
}
// 2. Temporary file: check Documents directory
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let tmpPath = docs.appendingPathComponent(materialId).path
if FileManager.default.fileExists(atPath: tmpPath) {
return (tmpPath, mt)
}
// 3. Check cache directory
let cache = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
let cachePath = cache.appendingPathComponent(materialId).path
if FileManager.default.fileExists(atPath: cachePath) {
return (cachePath, mt)
}
return nil
}
private static func resolveKnowledgeSource(id: String) -> String? {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
// Knowledge sources are stored as <Documents>/knowledge_sources/<id>/<filename>
let dir = docs.appendingPathComponent("knowledge_sources/\(id)")
guard let files = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil),
let first = files.first else { return nil }
return first.path
}
private static func materialTypeFrom(apiType: String?, title: String?) -> MaterialType {
switch apiType {
case "knowledge_source", "temporary_file":
// Infer from title extension
if let t = title?.lowercased() {
if t.hasSuffix(".md") { return .markdown }
if t.hasSuffix(".txt") { return .text }
if t.hasSuffix(".pdf") { return .pdf }
if t.hasSuffix(".epub") { return .epub }
if t.hasSuffix(".png") || t.hasSuffix(".jpg") || t.hasSuffix(".jpeg") || t.hasSuffix(".webp") { return .image }
}
return .unknown
default:
return .unknown
}
}
}

View File

@ -50,11 +50,29 @@ class ReadingAPIService {
// MARK: - Response DTOs // MARK: - Response DTOs
/// Codable wrapper for API's lastPosition (JSON object matching the server's ReadingPosition schema).
struct ReadingPositionValue: Codable {
let type: String?
let blockId: String?
let lineNumber: UInt32?
let pageNumber: UInt32?
let chapterId: String?
let scrollProgress: Float?
let pageProgress: Float?
let overallProgress: Float?
let zoomScale: Float?
let offsetX: Float?
let offsetY: Float?
let chapterProgress: Float?
}
struct MaterialReadingProgressDTO: Codable { struct MaterialReadingProgressDTO: Codable {
let status: String let status: String
let lastPosition: ReadingPositionValue?
let lastProgress: Float? let lastProgress: Float?
let totalActiveSeconds: Int let totalActiveSeconds: Int
let isMarkedRead: Bool let isMarkedRead: Bool
let sessionCount: Int?
let firstOpenedAt: String? let firstOpenedAt: String?
let lastReadAt: String? let lastReadAt: String?
} }

View File

@ -4,10 +4,12 @@ import Foundation
@MainActor @MainActor
class ActivityViewModel: ObservableObject { class ActivityViewModel: ObservableObject {
@Published var summary: ActivitySummary? @Published var summary: ActivitySummary?
@Published var learningSummary: LearningSummaryResponse?
@Published var focusItems: [FocusItem] = [] @Published var focusItems: [FocusItem] = []
@Published var heatmap: [String: Int] = [:] @Published var heatmap: [String: Int] = [:]
@Published var streak: ActivityStreak? @Published var streak: ActivityStreak?
@Published var trends: [ActivityTrend] = [] @Published var trends: [ActivityTrend] = []
@Published var readingTrend: LearningTrendResponse?
@Published var recommendations: [ActivityRecommendation] = [] @Published var recommendations: [ActivityRecommendation] = []
@Published var continueReading: ContinueLearningResponse? @Published var continueReading: ContinueLearningResponse?
@Published var recentRecords: [LearningRecordsResponse.RecordItem] = [] @Published var recentRecords: [LearningRecordsResponse.RecordItem] = []
@ -25,11 +27,15 @@ class ActivityViewModel: ObservableObject {
async let t = try? ActivityService.shared.trend() async let t = try? ActivityService.shared.trend()
async let r = try? ActivityService.shared.recommendations() async let r = try? ActivityService.shared.recommendations()
async let cr = try? ReadingAPIService.shared.getContinueLearning() async let cr = try? ReadingAPIService.shared.getContinueLearning()
async let ls = try? ReadingAPIService.shared.getLearningSummary()
async let ltr = try? ReadingAPIService.shared.getLearningTrend()
async let recs = try? ReadingAPIService.shared.getLearningRecords(limit: 10, type: "reading") async let recs = try? ReadingAPIService.shared.getLearningRecords(limit: 10, type: "reading")
let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult, continueResult, recordsResult) = await (s, f, h, st, t, r, cr, recs) let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult, continueResult, lsResult, ltrResult, recordsResult) = await (s, f, h, st, t, r, cr, ls, ltr, recs)
summary = summaryResult summary = summaryResult
learningSummary = lsResult
readingTrend = ltrResult
focusItems = focusResult ?? [] focusItems = focusResult ?? []
heatmap = heatmapResult ?? [:] heatmap = heatmapResult ?? [:]
streak = streakResult streak = streakResult
@ -55,11 +61,15 @@ class ActivityViewModel: ObservableObject {
async let t = try? ActivityService.shared.trend() async let t = try? ActivityService.shared.trend()
async let r = try? ActivityService.shared.recommendations() async let r = try? ActivityService.shared.recommendations()
async let cr = try? ReadingAPIService.shared.getContinueLearning() async let cr = try? ReadingAPIService.shared.getContinueLearning()
async let ls = try? ReadingAPIService.shared.getLearningSummary()
async let ltr = try? ReadingAPIService.shared.getLearningTrend()
async let recs = try? ReadingAPIService.shared.getLearningRecords(limit: 10, type: "reading") async let recs = try? ReadingAPIService.shared.getLearningRecords(limit: 10, type: "reading")
let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult, continueResult, recordsResult) = await (s, f, h, st, t, r, cr, recs) let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult, continueResult, lsResult, ltrResult, recordsResult) = await (s, f, h, st, t, r, cr, ls, ltr, recs)
summary = summaryResult summary = summaryResult
learningSummary = lsResult
readingTrend = ltrResult
focusItems = focusResult ?? [] focusItems = focusResult ?? []
heatmap = heatmapResult ?? [:] heatmap = heatmapResult ?? [:]
streak = streakResult streak = streakResult

View File

@ -37,10 +37,37 @@ struct AnalysisHomeView: View {
ZXStatBadge(icon: "exclamationmark.triangle", label: "复习卡片", value: "\(viewModel.summary?.totalCardsReviewed ?? 0)", trend: "", color: Color.zxYellow) ZXStatBadge(icon: "exclamationmark.triangle", 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) ZXStatBadge(icon: "chart.line.uptrend.xyaxis", label: "活跃天", value: "\(viewModel.summary?.activeDays ?? 0)", trend: "", color: Color.zxGreen)
} }
if let ls = viewModel.learningSummary {
HStack(spacing: 12) {
ZXStatBadge(icon: "book.pages", label: "今日阅读", value: "\(ls.todaySeconds / 60) 分钟", trend: "", color: Color.zxBlue)
ZXStatBadge(icon: "calendar.badge.clock", label: "本周阅读", value: "\(ls.weekSeconds / 60) 分钟", trend: "", color: Color.zxTeal)
ZXStatBadge(icon: "doc.text", label: "已读资料", value: "\(ls.markedReadCount)", trend: "", color: Color.zxGreen)
ZXStatBadge(icon: "books.vertical", label: "阅读资料", value: "\(ls.materialsReadCount)", trend: "", color: Color.zxIndigo)
}
}
VStack(alignment: .leading, spacing: 16) { 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) } HStack { Text("掌握度趋势").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0); Spacer(); Text("↑ +8% 本周").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxGreen) }
ZXChartView() ZXChartView()
}.padding(16).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20)) }.padding(16).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20))
if let rt = viewModel.readingTrend, !rt.series.isEmpty {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text("阅读时长趋势").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0)
Spacer()
Text("最近 \(rt.days)").font(.system(size: 12)).foregroundColor(Color.zxF04)
}
HStack(alignment: .bottom, spacing: 4) {
ForEach(rt.series, id: \.date) { pt in
VStack(spacing: 4) {
RoundedRectangle(cornerRadius: 3)
.fill(Color.zxPurple.opacity(0.6))
.frame(width: 12, height: barHeight(val: pt.value, max: rt.series.map(\.value).max() ?? 1))
Text(pt.date.suffix(5)).font(.system(size: 9)).foregroundColor(Color.zxF04).lineLimit(1)
}
}
}.frame(height: 80)
}.padding(16).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20))
}
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
Text("本周学习活跃").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0) Text("本周学习活跃").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0)
ZXWeekBarChart() ZXWeekBarChart()
@ -54,6 +81,30 @@ struct AnalysisHomeView: View {
ZXStatBadge(icon: "calendar", label: "最后活跃", value: streak.lastActiveDate.flatMap { String($0.prefix(10)) } ?? "-", trend: "", color: Color.zxPrimary) ZXStatBadge(icon: "calendar", label: "最后活跃", value: streak.lastActiveDate.flatMap { String($0.prefix(10)) } ?? "-", trend: "", color: Color.zxPrimary)
} }
} }
if !viewModel.recentRecords.isEmpty {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("学习记录").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0)
Spacer()
Text("最近 \(viewModel.recentRecords.count)").font(.system(size: 12)).foregroundColor(Color.zxF04)
}
ForEach(viewModel.recentRecords.prefix(5)) { rec in
HStack(spacing: 10) {
Circle().fill(rec.recordType == "reading" ? Color.zxPurple : Color.zxGreen).frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) {
Text(rec.title).font(.system(size: 13, weight: .medium)).foregroundColor(Color.zxF0).lineLimit(1)
Text(formatDuration(rec.durationSeconds)).font(.system(size: 11)).foregroundColor(Color.zxF04)
}
Spacer()
}.padding(.vertical, 4)
}
}
.padding(16)
.background(Color.zxFill004)
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 20))
}
if !viewModel.recommendations.isEmpty { if !viewModel.recommendations.isEmpty {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("学习推荐").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0) Text("学习推荐").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0)
@ -221,3 +272,8 @@ private func formatDuration(_ seconds: Int) -> String {
if seconds < 3600 { return "\(seconds / 60)m" } if seconds < 3600 { return "\(seconds / 60)m" }
return "\(seconds / 3600)h\(String(format: "%02d", (seconds % 3600) / 60))m" return "\(seconds / 3600)h\(String(format: "%02d", (seconds % 3600) / 60))m"
} }
private func barHeight(val: Int, max: Int) -> CGFloat {
guard max > 0 else { return 8 }
return max(8, CGFloat(val) / CGFloat(max) * 60)
}

View File

@ -637,7 +637,9 @@ struct LibraryDetailPage: View {
if let progress = try? await ReadingAPIService.shared.getReadingProgress( if let progress = try? await ReadingAPIService.shared.getReadingProgress(
materialId: src.id, targetType: "knowledge_source" materialId: src.id, targetType: "knowledge_source"
) { ) {
sourceReadingStatus[src.id] = progress.status sourceReadingStatus[src.id] = progress.isMarkedRead ? "read" : progress.status
} else if MarkedReadStore.shared.contains(src.id) {
sourceReadingStatus[src.id] = "read"
} }
} }
isLoadingSources = false isLoadingSources = false

View File

@ -0,0 +1,33 @@
import Foundation
/// Local UserDefaults-backed cache of materialIds that have been marked as read.
///
/// Provides offline resilience: if the API is unavailable when the reader
/// re-opens, the checkmark still reflects the user's prior action.
final class MarkedReadStore {
static let shared = MarkedReadStore()
private let defaults = UserDefaults.standard
private let key = "zx_marked_read_material_ids"
private var ids: Set<String>
private init() {
let arr = defaults.stringArray(forKey: key) ?? []
ids = Set(arr)
}
func contains(_ materialId: String) -> Bool {
ids.contains(materialId)
}
func mark(_ materialId: String) {
ids.insert(materialId)
defaults.set(Array(ids), forKey: key)
}
func unmark(_ materialId: String) {
ids.remove(materialId)
defaults.set(Array(ids), forKey: key)
}
}

View File

@ -10,6 +10,9 @@ final class MaterialDetailViewModel: ObservableObject {
@Published var quizCount: Int = 0 @Published var quizCount: Int = 0
@Published var isLoading = true @Published var isLoading = true
@Published var aiStatus: AIStatus = .processing @Published var aiStatus: AIStatus = .processing
@Published var readingStatus: String?
@Published var readingSeconds: Int = 0
@Published var isMarkedRead: Bool = false
enum AIStatus: Equatable { enum AIStatus: Equatable {
case processing // AI case processing // AI
@ -18,26 +21,36 @@ final class MaterialDetailViewModel: ObservableObject {
} }
let knowledgeBaseId: String let knowledgeBaseId: String
let materialId: String?
init(knowledgeBaseId: String) { init(knowledgeBaseId: String, materialId: String? = nil) {
self.knowledgeBaseId = knowledgeBaseId self.knowledgeBaseId = knowledgeBaseId
self.materialId = materialId
} }
func load() async { func load() async {
isLoading = true isLoading = true
do {
kb = try await KnowledgeBaseService.shared.detail(id: knowledgeBaseId)
} catch {}
do { async let kbResult = try? KnowledgeBaseService.shared.detail(id: knowledgeBaseId)
let items = try await KnowledgeItemService.shared.list(knowledgeBaseId: knowledgeBaseId) async let itemsResult = try? KnowledgeItemService.shared.list(knowledgeBaseId: knowledgeBaseId)
itemCount = items.count async let quizzesResult = try? QuizService.shared.list(knowledgeBaseId: knowledgeBaseId)
} catch {} async let progressResult: MaterialReadingProgressDTO? = {
if let mid = materialId {
return try? await ReadingAPIService.shared.getReadingProgress(materialId: mid, targetType: "knowledge_source")
}
return nil
}()
do { let (kbR, itemsR, quizzesR, progR) = await (kbResult, itemsResult, quizzesResult, progressResult)
let quizzes = try await QuizService.shared.list(knowledgeBaseId: knowledgeBaseId)
quizCount = quizzes.count kb = kbR
} catch {} itemCount = itemsR?.count ?? 0
quizCount = quizzesR?.count ?? 0
if let p = progR {
readingStatus = p.status
readingSeconds = p.totalActiveSeconds
isMarkedRead = p.isMarkedRead
}
aiStatus = itemCount > 0 ? .ready : .processing aiStatus = itemCount > 0 ? .ready : .processing
isLoading = false isLoading = false
@ -122,6 +135,11 @@ struct MaterialDetailView: View {
.clipShape(RoundedRectangle(cornerRadius: 16)) .clipShape(RoundedRectangle(cornerRadius: 16))
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxHairline, lineWidth: 1)) .overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxHairline, lineWidth: 1))
// Reading progress
if let status = vm.readingStatus, status != "not_started" {
readingProgressCard
}
// Read original file button // Read original file button
NavigationLink(value: Route.materialReader( NavigationLink(value: Route.materialReader(
materialId: knowledgeBaseId, materialId: knowledgeBaseId,
@ -186,6 +204,42 @@ struct MaterialDetailView: View {
} }
} }
var readingProgressCard: some View {
VStack(spacing: 0) {
HStack(spacing: 12) {
Image(systemName: vm.isMarkedRead ? "checkmark.circle.fill" : "book.fill")
.font(.system(size: 16))
.foregroundColor(vm.isMarkedRead ? Color.zxGreen : Color.zxPurple)
VStack(alignment: .leading, spacing: 2) {
Text(vm.isMarkedRead ? "已读完" : statusLabel(vm.readingStatus))
.font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0)
Text("累计阅读 \(formatSeconds(vm.readingSeconds))")
.font(.system(size: 12)).foregroundColor(Color.zxF04)
}
Spacer()
}
.padding(12)
}
.background(Color.zxSurfaceElevated)
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxHairline, lineWidth: 1))
}
func statusLabel(_ s: String?) -> String {
switch s {
case "completed": return "阅读完成"
case "in_progress": return "阅读中"
default: return "已开始"
}
}
func formatSeconds(_ s: Int) -> String {
if s < 60 { return "\(s)" }
let m = s / 60
if m < 60 { return "\(m) 分钟" }
return "\(m / 60) 小时 \(m % 60) 分钟"
}
// MARK: - File info row // MARK: - File info row
func fileInfoRow(icon: String, label: String, value: String) -> some View { func fileInfoRow(icon: String, label: String, value: String) -> some View {

View File

@ -104,6 +104,7 @@ struct MaterialReaderView: View {
@State private var isMarkedRead = false @State private var isMarkedRead = false
@State private var readingStatus: String? @State private var readingStatus: String?
@State private var readingProgressSeconds: Int = 0 @State private var readingProgressSeconds: Int = 0
@State private var apiRestorePosition: ReadingPosition?
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@ -349,10 +350,16 @@ struct MaterialReaderView: View {
await MainActor.run { await MainActor.run {
readingStatus = progress.status readingStatus = progress.status
readingProgressSeconds = progress.totalActiveSeconds readingProgressSeconds = progress.totalActiveSeconds
if progress.isMarkedRead { isMarkedRead = true } if progress.isMarkedRead { isMarkedRead = true; MarkedReadStore.shared.mark(vm.materialId) }
if let lp = progress.lastPosition {
apiRestorePosition = ReadingPositionAdapter.fromValue(lp)
}
} }
} catch { } catch {
print("[READER] Progress query failed: \(error)") // Fallback to local cache when API unavailable
if MarkedReadStore.shared.contains(vm.materialId) { isMarkedRead = true }
apiRestorePosition = positionStore.load(materialId: vm.materialId)
print("[READER] Progress query failed, using local position: \(error)")
} }
} }
} }
@ -379,6 +386,7 @@ struct MaterialReaderView: View {
private func markAsRead() { private func markAsRead() {
guard !isMarkedRead else { return } guard !isMarkedRead else { return }
isMarkedRead = true isMarkedRead = true
MarkedReadStore.shared.mark(vm.materialId)
if isV2Active { if isV2Active {
sessionManager.markAsRead() sessionManager.markAsRead()
} else { } else {
@ -394,17 +402,23 @@ struct MaterialReaderView: View {
} }
/// Restore saved reading position on re-entry. /// Restore saved reading position on re-entry.
/// Priority: API lastPosition > local positionStore > session lastPosition
private func restorePosition() { private func restorePosition() {
let targetType = knowledgeBaseId != nil ? "knowledge_source" : "temporary_file" // If API already fetched a position, use it immediately
if let apiPos = apiRestorePosition {
Task { applyPosition(apiPos)
// 1. Try API for status (but use local cache for position data) return
let _ = try? await readingAPI.getReadingProgress(materialId: vm.materialId, targetType: targetType)
// 2. Restore position from local cache
if let saved = positionStore.load(materialId: vm.materialId) {
await MainActor.run { applyPosition(saved) }
} }
// Fallback: local store
if let saved = positionStore.load(materialId: vm.materialId) {
applyPosition(saved)
return
}
// Last resort: session/collector
if let pos = sessionManager.lastPosition ?? collector.lastPosition {
applyPosition(pos)
} }
} }

View File

@ -74,6 +74,26 @@ struct ReadingPositionAdapter {
) )
} }
/// Convert an API `ReadingPositionValue` DTO into a `ReadingPosition` enum.
static func fromValue(_ v: ReadingPositionValue) -> ReadingPosition? {
switch v.type {
case "Markdown":
guard let bid = v.blockId else { return nil }
return .markdown(blockId: bid, scrollProgress: clamp(v.scrollProgress ?? 0))
case "Text":
return .text(lineNumber: v.lineNumber ?? 1, scrollProgress: clamp(v.scrollProgress ?? 0))
case "Pdf":
return .pdf(pageNumber: v.pageNumber ?? 1, pageProgress: clamp(v.pageProgress ?? 0), overallProgress: clamp(v.overallProgress ?? 0))
case "Image":
return .image(zoomScale: v.zoomScale ?? 1.0, offsetX: v.offsetX ?? 0, offsetY: v.offsetY ?? 0)
case "Epub":
guard let cid = v.chapterId else { return nil }
return .epub(chapterId: cid, chapterProgress: clamp(v.chapterProgress ?? 0), overallProgress: clamp(v.overallProgress ?? 0))
default:
return .unknown
}
}
// MARK: - Helpers // MARK: - Helpers
private static func clamp(_ value: Float) -> Float { private static func clamp(_ value: Float) -> Float {

View File

@ -121,14 +121,14 @@ struct StudyHomeView: View {
@ViewBuilder @ViewBuilder
private var mainActionCard: some View { private var mainActionCard: some View {
switch vm.mainAction { switch vm.mainAction {
case .continueSession(let kbTitle, let elapsed): case .continueSession(let materialId, let filePath, let materialType, let kbTitle, let elapsed, _):
actionCard( actionCard(
title: "继续上次学习", title: "继续上次学习",
subtitle: "\(kbTitle)》· \(elapsed)", subtitle: "\(kbTitle)》· \(elapsed)",
icon: "arrow.triangle.2.circlepath", icon: "arrow.triangle.2.circlepath",
color: Color.zxPurple, color: Color.zxPurple,
cta: "继续学习", cta: "继续学习",
route: .learningSession(taskTitle: "继续学习", taskType: "study", taskColorHex: "#3D7FFB") route: .materialReader(materialId: materialId, filePath: filePath, materialType: materialType, title: kbTitle)
) )
case .todaysReview(let count, let minutes): case .todaysReview(let count, let minutes):
actionCard( actionCard(

View File

@ -4,7 +4,7 @@ import Foundation
// MARK: - Action states // MARK: - Action states
enum MainAction: Equatable { enum MainAction: Equatable {
case continueSession(kbTitle: String, elapsed: String) case continueSession(materialId: String, filePath: String, materialType: MaterialType, kbTitle: String, elapsed: String, resumePosition: ReadingPosition?)
case todaysReview(count: Int, estimatedMinutes: Int) case todaysReview(count: Int, estimatedMinutes: Int)
case selfTest(quizId: String, count: Int) case selfTest(quizId: String, count: Int)
case startLearning(knowledgeBaseId: String, kbCount: Int) case startLearning(knowledgeBaseId: String, kbCount: Int)
@ -59,7 +59,7 @@ final class StudyHomeViewModel: ObservableObject {
firstKbId = kbResult.first?.id firstKbId = kbResult.first?.id
// Evaluate main action // Evaluate main action
mainAction = evaluatePriority( mainAction = await evaluatePriority(
sessions: sessionsResult, sessions: sessionsResult,
dueCards: dueCardsResult, dueCards: dueCardsResult,
quizzes: quizzesResult, quizzes: quizzesResult,
@ -103,7 +103,7 @@ final class StudyHomeViewModel: ObservableObject {
firstQuizId = quizzesResult.first?.id firstQuizId = quizzesResult.first?.id
firstKbId = kbResult.first?.id firstKbId = kbResult.first?.id
mainAction = evaluatePriority( mainAction = await evaluatePriority(
sessions: sessionsResult, sessions: sessionsResult,
dueCards: dueCardsResult, dueCards: dueCardsResult,
quizzes: quizzesResult, quizzes: quizzesResult,
@ -126,15 +126,26 @@ final class StudyHomeViewModel: ObservableObject {
dueCards: [ReviewCard], dueCards: [ReviewCard],
quizzes: [Quiz], quizzes: [Quiz],
knowledgeBases: [KnowledgeBase] knowledgeBases: [KnowledgeBase]
) -> MainAction { ) async -> MainAction {
// Priority 1: Unfinished session // Priority 1: API continue-learning (most recent material with progress)
if let cl = try? await ReadingAPIService.shared.getContinueLearning(),
let materialId = cl.materialId, !materialId.isEmpty,
let resolved = MaterialPathResolver.resolve(materialId: materialId, apiType: cl.type, title: cl.title) {
let kbTitle = cl.title ?? knowledgeBases.first?.title ?? "学习"
let elapsed = formatElapsedSince(cl.lastReadAt)
let pos = cl.lastPosition.flatMap { ReadingPositionAdapter.fromValue($0) }
return .continueSession(materialId: materialId, filePath: resolved.filePath, materialType: resolved.materialType, kbTitle: kbTitle, elapsed: elapsed, resumePosition: pos)
}
// Priority 2: Unfinished session (fallback)
if let unfinished = sessions if let unfinished = sessions
.filter({ $0.status != nil && $0.status != "completed" }) .filter({ $0.status != nil && $0.status != "completed" })
.sorted(by: { ($0.startedAt ?? "") > ($1.startedAt ?? "") }) .sorted(by: { ($0.startedAt ?? "") > ($1.startedAt ?? "") })
.first { .first {
let kbTitle = knowledgeBases.first(where: { $0.id == unfinished.knowledgeBaseId })?.title ?? "学习" let kbTitle = knowledgeBases.first(where: { $0.id == unfinished.knowledgeBaseId })?.title ?? "学习"
let elapsed = formatElapsedSince(unfinished.startedAt) let elapsed = formatElapsedSince(unfinished.startedAt)
return .continueSession(kbTitle: kbTitle, elapsed: elapsed) let mt: MaterialType = unfinished.readingTargetType == "knowledge_source" ? .unknown : .unknown
return .continueSession(materialId: unfinished.materialId ?? "", filePath: "", materialType: mt, kbTitle: kbTitle, elapsed: elapsed, resumePosition: nil)
} }
// Priority 2: Today's review // Priority 2: Today's review