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:
parent
3abe17a84e
commit
82bcbb9efb
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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?
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user