960 lines
39 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
import QuickLook
import Combine
// MARK: - Preview mode mapping (mirrors Rust MaterialType::preview_mode)
func previewMode(for type: MaterialType) -> PreviewMode {
switch type {
case .markdown, .text, .image, .epub: .nativeReader
case .pdf, .word, .excel: .platformPreview
case .powerPoint: .externalOpen
case .unknown: .unsupported
}
}
// MARK: - ViewModel
@MainActor
final class MaterialReaderViewModel: ObservableObject {
@Published var loadingState: LoadState = .loading
@Published var blocks: [DocumentBlock] = []
@Published var textContent: String = ""
@Published var imageMeta: ImageMeta?
@Published var stats: TextStats?
let materialId: String
var filePath: String
let materialType: MaterialType
let mode: PreviewMode
enum LoadState: Equatable {
case idle, loading, loaded, error(String)
}
init(materialId: String, filePath: String, materialType: MaterialType) {
self.materialId = materialId
self.filePath = filePath
self.materialType = materialType
self.mode = previewMode(for: materialType)
print("[READER] init — materialId=\(materialId), type=\(materialType), path=\(filePath)")
}
func load() async {
loadingState = .loading
print("[READER] load() start — materialType=\(materialType)")
// Check file before doing anything
let fileExists = FileManager.default.fileExists(atPath: filePath)
let fileSize: Int = (try? FileManager.default.attributesOfItem(atPath: filePath)[.size] as? Int) ?? -1
print("[READER] file exists=\(fileExists), size=\(fileSize)")
do {
switch materialType {
case .markdown:
let t0 = CFAbsoluteTimeGetCurrent()
let content = try String(contentsOfFile: filePath, encoding: .utf8)
let t1 = CFAbsoluteTimeGetCurrent()
print("[READER] markdown read — contentLength=\(content.count), readMs=\((t1-t0)*1000)")
print("[READER] calling parseMarkdown...")
blocks = try parseMarkdown(content: content)
let t2 = CFAbsoluteTimeGetCurrent()
print("[READER] parseMarkdown done — blockCount=\(blocks.count), parseMs=\((t2-t1)*1000)")
case .text:
let t0 = CFAbsoluteTimeGetCurrent()
let content = try String(contentsOfFile: filePath, encoding: .utf8)
let t1 = CFAbsoluteTimeGetCurrent()
print("[READER] text read — contentLength=\(content.count), readMs=\((t1-t0)*1000)")
print("[READER] calling parseText...")
blocks = try parseText(content: content)
let t2 = CFAbsoluteTimeGetCurrent()
print("[READER] parseText done — blockCount=\(blocks.count), parseMs=\((t2-t1)*1000)")
print("[READER] calling readTextStats...")
stats = try? readTextStats(filePath: filePath)
print("[READER] readTextStats done — stats=\(String(describing: stats))")
case .image:
print("[READER] calling readImageMeta...")
imageMeta = try readImageMeta(filePath: filePath)
print("[READER] readImageMeta done — meta=\(String(describing: imageMeta))")
case .pdf, .word, .excel, .powerPoint, .epub, .unknown:
print("[READER] unsupported/native type — skipping Rust call")
}
loadingState = .loaded
print("[READER] load() finished successfully")
} catch let error as DocumentError {
print("[READER] DocumentError — \(error.localizedDescription), type=\(error)")
loadingState = .error(error.localizedDescription)
} catch {
print("[READER] error — \(error.localizedDescription), type=\(type(of: error))")
loadingState = .error(error.localizedDescription)
}
}
}
// MARK: - Main View
struct MaterialReaderView: View {
private enum BootstrapState: Equatable {
case idle
case bootstrapping
case ready
case failed(String)
}
@StateObject private var vm: MaterialReaderViewModel
@Binding var showQuickLook: Bool
@Binding var showNoteSheet: Bool
@State private var scrollProgress: CGFloat = 0
@State private var bootstrapState: BootstrapState = .idle
@State private var pendingRestoreBlockId: String?
@State private var isInitialRestoreComplete = false
@State private var isProgrammaticScrollInFlight = false
@State private var actualContentHeight: CGFloat = 1
@State private var isMarkedRead = false
@State private var readingStatus: String?
@State private var readingProgressSeconds: Int = 0
@State private var apiRestorePosition: ReadingPosition?
@State private var localRestoreCheckpoint: ReadingCheckpoint?
@State private var flushTimer: Timer?
@Environment(\.scenePhase) private var scenePhase
@State private var isParsing = false
@State private var parseMessage = ""
@State private var showParseOverlay = false
@State private var latestKnowledgeTask: KnowledgeGenerationTaskStatus?
@State private var knowledgeTaskPollingTask: Task<Void, Never>?
private let title: String
private let knowledgeBaseId: String?
// V2 session manager
private let sessionManager = ReadingRuntimeSessionManager.shared
private let positionStore = ReadingPositionStore.shared
private let readingAPI = ReadingAPIService.shared
/// Whether the V2 session manager is active (has an open session).
private var isV2Active: Bool {
sessionManager.state == .active || sessionManager.state == .paused
}
init(materialId: String, filePath: String, materialType: MaterialType, knowledgeBaseId: String? = nil, title: String = "", resumePosition: ReadingPositionValue? = nil, showQuickLook: Binding<Bool> = .constant(false), showNoteSheet: Binding<Bool> = .constant(false)) {
self.title = title
self.knowledgeBaseId = knowledgeBaseId
self._showQuickLook = showQuickLook
self._showNoteSheet = showNoteSheet
_vm = StateObject(wrappedValue: MaterialReaderViewModel(
materialId: materialId, filePath: filePath, materialType: materialType))
if let rp = resumePosition {
_apiRestorePosition = State(initialValue: ReadingPositionAdapter.fromValue(rp))
}
}
var body: some View {
ZStack {
Color.zxCanvas.ignoresSafeArea()
switch vm.loadingState {
case .idle, .loading:
VStack(spacing: 12) {
ProgressView().tint(Color.zxPrimary)
Text("加载资料…").font(.system(size: 14)).foregroundColor(Color.zxF04)
}
case .error(let msg):
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle").font(.system(size: 40)).foregroundColor(Color.zxCoral)
Text(msg).font(.system(size: 14)).foregroundColor(Color.zxF04)
Button("重试") { Task { await bootstrapReader(forceReload: true) } }
.font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxPrimary)
.padding(.horizontal, 24).padding(.vertical, 10)
.background(Color.zxPrimarySoft).clipShape(RoundedRectangle(cornerRadius: 10))
}
case .loaded:
switch vm.mode {
case .nativeReader:
nativeReaderBody
case .platformPreview:
platformPreviewBody
case .externalOpen:
externalOpenBody
case .unsupported:
unsupportedBody
}
}
}
.navigationTitle(title.isEmpty ? vm.materialType.displayName : title)
.navigationBarTitleDisplayMode(.inline)
.toolbar(.hidden, for: .tabBar)
.toolbarBackground(.hidden, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 4) {
Button { markAsRead() } label: {
Image(systemName: isMarkedRead ? "checkmark.circle.fill" : "checkmark.circle")
.font(.system(size: 16))
.foregroundColor(isMarkedRead ? Color.green : Color.zxF05)
}
if let kbId = knowledgeBaseId {
Menu {
NavigationLink(value: Route.aiChat(context: ChatEntryContext(
scopeType: .material, scopeId: vm.materialId, scopeName: title,
parentKnowledgeBaseId: knowledgeBaseId, createdFrom: "material_reader"
))) {
Label("AI 对话", systemImage: "bubble.left.and.bubble.right")
}
NavigationLink(value: Route.materialDetail(
knowledgeBaseId: kbId, sourceId: vm.materialId, fileName: title.isEmpty ? "资料" : title,
fileType: vm.materialType, fileSize: 0, filePath: vm.filePath, uploadDate: nil
)) {
Label("资料详情", systemImage: "info.circle")
}
NavigationLink(value: Route.learningSession(
taskTitle: title.isEmpty ? "学习资料" : title, taskType: "study", taskColorHex: "#3D7FFB"
)) {
Label("学习会话", systemImage: "arrow.triangle.2.circlepath")
}
Button {
Task { await generateKnowledgePoints(kbId: kbId, sourceId: vm.materialId) }
} label: {
Label(generateKnowledgeMenuTitle, systemImage: "brain.head.profile")
}
.disabled(isKnowledgeTaskActive)
} label: {
Image(systemName: "ellipsis.circle")
.font(.system(size: 20))
.foregroundColor(Color.zxF05)
}
}
}
}
}
.overlay(alignment: .bottom) {
if showParseOverlay {
HStack(spacing: 8) {
if isParsing { ProgressView().tint(.white) }
Text(parseMessage).font(.system(size: 13, weight: .medium)).foregroundColor(.white)
}
.padding(.horizontal, 16).padding(.vertical, 10)
.background(Color.black.opacity(0.75))
.clipShape(Capsule())
.padding(.bottom, 100)
}
}
.task {
await bootstrapReader()
await refreshKnowledgeTaskStatus()
restartKnowledgeTaskPollingIfNeeded()
}
.onDisappear { stopKnowledgeTaskPolling() }
.sheet(isPresented: $showQuickLook) {
QuickLookPreview(url: URL(fileURLWithPath: vm.filePath))
}
.sheet(isPresented: $showNoteSheet) {
QuickNoteSheet(
materialId: vm.materialId,
materialName: vm.materialType.displayName,
anchor: buildAnchor()
)
}
.onAppear {
// 2 退
if flushTimer == nil {
flushTimer = Timer.scheduledTimer(withTimeInterval: 120, repeats: true) { _ in
Task {
await ReadingEventUploadPipeline.shared.exportAndEnqueue()
await ReadingEventUploadPipeline.shared.flush()
}
}
}
}
.onDisappear {
flushTimer?.invalidate()
flushTimer = nil
// 1. Rust push material_closed
closeReadingSession()
// 2. material_closed+
// 3. context
Task {
await ReadingEventUploadPipeline.shared.exportAndEnqueue()
await ReadingEventUploadPipeline.shared.flush()
sessionManager.cleanup()
}
}
.onChange(of: scenePhase) { _, newPhase in
switch newPhase {
case .background:
if isV2Active {
sessionManager.pause()
print("[READER] Scene → background, session paused")
}
// Flush: export enqueue
Task {
await ReadingEventUploadPipeline.shared.exportAndEnqueue()
await ReadingEventUploadPipeline.shared.flush()
}
case .active:
if sessionManager.state == .paused {
sessionManager.resume()
print("[READER] Scene → active, session resumed")
}
case .inactive:
break
@unknown default:
break
}
}
.onChange(of: apiRestorePosition) { _, newPos in
if newPos != nil,
vm.loadingState == .loaded,
!isInitialRestoreComplete,
pendingRestoreBlockId == nil {
restoreInitialPositionIfNeeded()
}
}
}
// MARK: - Native reader (Markdown, TXT, Image)
@ViewBuilder
var nativeReaderBody: some View {
if vm.materialType == .image, let meta = vm.imageMeta {
imageBody(meta: meta)
} else if !vm.blocks.isEmpty {
markdownBody
} else if !vm.textContent.isEmpty {
textBody
}
}
// MARK: Markdown block list
var markdownBody: some View {
ScrollViewReader { scrollProxy in
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// Reading progress indicator
if let status = readingStatus, status != "not_started" {
HStack(spacing: 8) {
Image(systemName: status == "read" ? "book.closed.fill" : "book.pages")
.font(.system(size: 11))
Text(status == "read" ? "已读完" : "阅读中")
.font(.system(size: 11, weight: .medium))
if readingProgressSeconds > 0 {
Text("· \(formatDuration(readingProgressSeconds))")
.font(.system(size: 11))
}
}
.foregroundColor(status == "read" ? Color.green : Color.zxF03)
.padding(.horizontal, 10).padding(.vertical, 4)
.background(Color.zxFill004)
.clipShape(Capsule())
}
if let s = vm.stats {
HStack(spacing: 16) {
Label("\(s.lineCount)", systemImage: "text.alignleft")
Label("\(s.wordCount)", systemImage: "character")
}
.font(.system(size: 12)).foregroundColor(Color.zxF03)
.padding(.bottom, 4)
}
ForEach(Array(vm.blocks.enumerated()), id: \.offset) { _, block in
DocumentBlockView(block: block)
.id(ReadingPositionAdapter.blockId(from: block))
}
}
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
.background(GeometryReader { geo in
Color.clear
.preference(key: ScrollOffsetKey.self, value: geo.frame(in: .named("scroll")).minY)
.onAppear { actualContentHeight = geo.size.height }
})
.onChange(of: vm.blocks.count) { _,_ in
// Height will be updated by GeometryReader on next render
}
}
.coordinateSpace(name: "scroll")
.onPreferenceChange(ScrollOffsetKey.self) { offset in
scrollProgress = min(1, max(0, -offset / max(actualContentHeight, 1)))
reportScrollPosition()
}
.onAppear {
consumePendingRestore(using: scrollProxy)
}
.onChange(of: pendingRestoreBlockId) { _, target in
guard target != nil else { return }
consumePendingRestore(using: scrollProxy)
}
.scrollIndicators(.hidden)
}
}
// MARK: - Reading Session Lifecycle
private func openReadingSession() {
let fileName = URL(fileURLWithPath: vm.filePath).lastPathComponent
let context = ReadingMaterialContext(
readingTargetType: knowledgeBaseId != nil ? .knowledgeSource : .temporaryFile,
materialId: vm.materialId,
knowledgeBaseId: knowledgeBaseId,
title: title.isEmpty ? nil : title,
materialType: vm.materialType,
filePath: vm.filePath,
fileName: fileName,
fileType: vm.materialType.fileTypeString
)
// V2 session
do {
try sessionManager.openMaterial(context)
print("[READER] V2 session opened — materialId=\(vm.materialId)")
} catch {
print("[READER] V2 session open FAILED: \(error) — materialId=\(vm.materialId)")
}
}
private func bootstrapReader(forceReload: Bool = false) async {
if bootstrapState == .bootstrapping {
return
}
if bootstrapState == .ready && !forceReload {
return
}
bootstrapState = .bootstrapping
pendingRestoreBlockId = nil
isProgrammaticScrollInFlight = false
isInitialRestoreComplete = false
await ensureLocalFileIfNeeded()
await vm.load()
guard vm.loadingState == .loaded else {
if case .error(let message) = vm.loadingState {
bootstrapState = .failed(message)
}
return
}
openReadingSession()
await fetchReadingProgress()
restoreInitialPositionIfNeeded()
if pendingRestoreBlockId == nil {
isInitialRestoreComplete = true
}
bootstrapState = .ready
}
private func closeReadingSession() {
// V2 session
if sessionManager.state == .active || sessionManager.state == .paused {
if let lastPos = sessionManager.lastPosition {
positionStore.save(materialId: vm.materialId, position: lastPos)
}
sessionManager.closeMaterial()
print("[READER] V2 session closed")
}
}
/// Mark the current material as read optimistic update + event push.
private func markAsRead() {
guard !isMarkedRead else { return }
isMarkedRead = true
MarkedReadStore.shared.mark(vm.materialId)
sessionManager.markAsRead()
}
/// Build a NoteAnchor from the current scroll position (for quick note).
private func buildAnchor() -> NoteAnchor? {
let pos = sessionManager.lastPosition
guard let pos else { return nil }
return createNoteAnchor(materialId: vm.materialId, position: pos)
}
private func ensureLocalFileIfNeeded() async {
guard vm.filePath.isEmpty || !FileManager.default.fileExists(atPath: vm.filePath) else {
return
}
guard let kbId = knowledgeBaseId else {
return
}
print("[MaterialReader] No local file, downloading from COS: \(vm.materialId)")
do {
let source = try await KnowledgeSourceService.shared.detail(kbId: kbId, id: vm.materialId)
if let fileId = source.fileId {
let downloadUrl = try await FileUploadService.shared.getDownloadUrl(fileId: fileId)
let url = URL(string: downloadUrl)!
let (data, _) = try await URLSession.shared.data(from: url)
let ext = source.originalFilename?.split(separator: ".").last.map(String.init) ?? "md"
let localURL = FileManager.default.temporaryDirectory
.appendingPathComponent("source_\(vm.materialId).\(ext)")
try data.write(to: localURL)
print("[MaterialReader] Downloaded to: \(localURL.path), size: \(data.count)")
vm.filePath = localURL.path
}
} catch {
print("[MaterialReader] COS download failed: \(error.localizedDescription)")
}
}
private func fetchReadingProgress() async {
let targetType = knowledgeBaseId != nil ? "knowledge_source" : "temporary_file"
do {
let progress = try await readingAPI.getReadingProgress(materialId: vm.materialId, targetType: targetType)
readingStatus = progress.status
readingProgressSeconds = progress.totalActiveSeconds ?? 0
if progress.isMarkedRead == true {
isMarkedRead = true
MarkedReadStore.shared.mark(vm.materialId)
}
if let lp = progress.lastPosition {
apiRestorePosition = ReadingPositionAdapter.fromValue(lp)
}
} catch {
if MarkedReadStore.shared.contains(vm.materialId) {
isMarkedRead = true
}
localRestoreCheckpoint = positionStore.loadCheckpoint(materialId: vm.materialId)
apiRestorePosition = localRestoreCheckpoint?.position ?? positionStore.load(materialId: vm.materialId)
print("[READER] Progress query failed, using local position: \(error)")
}
}
/// Restore saved reading position on re-entry.
/// Same-device local checkpoints are preferred over API progress because
/// the home "continue learning" flow can reopen the reader before the
/// latest uploaded position has been processed server-side.
private func restoreInitialPositionIfNeeded() {
guard !isInitialRestoreComplete else { return }
if let checkpoint = localRestoreCheckpoint,
sessionManager.isCompatibleRestoreCheckpoint(checkpoint) {
applyPosition(checkpoint.position)
return
}
if let checkpoint = positionStore.loadCheckpoint(materialId: vm.materialId),
sessionManager.isCompatibleRestoreCheckpoint(checkpoint) {
localRestoreCheckpoint = checkpoint
applyPosition(checkpoint.position)
return
}
if localRestoreCheckpoint != nil {
print("[READER] Skip incompatible local checkpoint — materialId=\(vm.materialId)")
localRestoreCheckpoint = nil
}
if let saved = positionStore.load(materialId: vm.materialId) {
applyPosition(saved)
return
}
if let apiPos = apiRestorePosition {
applyPosition(apiPos)
return
}
if let pos = sessionManager.lastPosition {
applyPosition(pos)
return
}
isInitialRestoreComplete = true
}
private func applyPosition(_ pos: ReadingPosition) {
if let restoreBlockId = ReadingPositionAdapter.blockIdForRestore(from: pos, in: vm.blocks) {
pendingRestoreBlockId = restoreBlockId
return
}
switch pos {
case .block, .markdown, .text:
isInitialRestoreComplete = true
print("[READER] Restore target missing, skipped — materialId=\(vm.materialId)")
case .pdf, .pdfViewport, .image, .imageViewport, .workbook, .slide, .epub, .unknown:
isInitialRestoreComplete = true
}
}
private func confirmRestoredPosition() {
guard let restoreBlockId = pendingRestoreBlockId else { return }
guard let restoredIndex = ReadingPositionAdapter.blockIndex(for: restoreBlockId, in: vm.blocks) else { return }
let restoredProgress: Float
if vm.blocks.count <= 1 {
restoredProgress = 0
} else {
restoredProgress = Float(restoredIndex) / Float(vm.blocks.count - 1)
}
guard let position = ReadingPositionAdapter.fromBlockScroll(
materialType: vm.materialType,
blocks: vm.blocks,
scrollProgress: restoredProgress,
blockIndex: restoredIndex
) else { return }
if sessionManager.state == .active {
sessionManager.updatePosition(position)
}
}
private func consumePendingRestore(using scrollProxy: ScrollViewProxy) {
guard let restoreBlockId = pendingRestoreBlockId else { return }
isProgrammaticScrollInFlight = true
DispatchQueue.main.async {
scrollProxy.scrollTo(restoreBlockId, anchor: .top)
DispatchQueue.main.async {
confirmRestoredPosition()
pendingRestoreBlockId = nil
isProgrammaticScrollInFlight = false
isInitialRestoreComplete = true
}
}
}
/// Report current scroll position to the active session manager.
private func reportScrollPosition() {
guard !vm.blocks.isEmpty else { return }
guard isInitialRestoreComplete else { return }
guard !isProgrammaticScrollInFlight else { return }
let idx = max(0, min(vm.blocks.count - 1,
Int(scrollProgress * CGFloat(vm.blocks.count))))
let sp = Float(scrollProgress)
guard let pos = ReadingPositionAdapter.fromBlockScroll(
materialType: vm.materialType,
blocks: vm.blocks,
scrollProgress: sp,
blockIndex: idx
) else { return }
// V2 primary
if sessionManager.state == .active {
sessionManager.updatePosition(pos)
}
}
// MARK: Plain text fallback
var textBody: some View {
ScrollView {
Text(vm.textContent)
.font(.system(size: 15)).foregroundColor(Color.zxF0).lineSpacing(6)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
}
.scrollIndicators(.hidden)
}
// MARK: Image
func imageBody(meta: ImageMeta) -> some View {
VStack {
if let uiImage = UIImage(contentsOfFile: vm.filePath) {
Image(uiImage: uiImage).resizable().scaledToFit()
.frame(maxWidth: .infinity, maxHeight: .infinity).padding(20)
}
Text("\(meta.width)×\(meta.height) · \(meta.format.uppercased()) · \(formatFileSize(meta.fileSize))")
.font(.system(size: 12)).foregroundColor(Color.zxF03)
}
}
// MARK: - Platform preview (PDF, Word, Excel)
var platformPreviewBody: some View {
VStack(spacing: 24) {
VStack(spacing: 12) {
Image(systemName: "doc.text").font(.system(size: 48)).foregroundColor(Color.zxF03)
Text(vm.materialType.displayName).font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0)
Text("使用系统预览打开此文件").font(.system(size: 14)).foregroundColor(Color.zxF04)
}
Button { showQuickLook = true } label: {
HStack(spacing: 8) { Image(systemName: "eye"); Text("打开预览") }
.font(.system(size: 14, weight: .bold)).foregroundColor(.white)
.frame(maxWidth: .infinity).frame(height: 48)
.background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: ZXRadius.lg))
}.padding(.horizontal, 40)
}
}
// MARK: - External open (PPT)
var externalOpenBody: some View {
VStack(spacing: 24) {
VStack(spacing: 12) {
Image(systemName: "arrow.up.forward.app").font(.system(size: 48)).foregroundColor(Color.zxF03)
Text("PowerPoint 演示文稿").font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0)
Text("此格式需要外部应用打开").font(.system(size: 14)).foregroundColor(Color.zxF04)
}
if let url = URL(string: "shareddocuments://" + vm.filePath) {
ShareLink(item: url) {
HStack(spacing: 8) { Image(systemName: "square.and.arrow.up"); Text("用其他应用打开") }
.font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxPrimary)
.frame(maxWidth: .infinity).frame(height: 48)
.background(Color.zxPrimarySoft).clipShape(RoundedRectangle(cornerRadius: ZXRadius.lg))
}
}
}
}
// MARK: - Parse Trigger
private func generateKnowledgePoints(kbId: String, sourceId: String) async {
guard !isParsing, !isKnowledgeTaskActive else { return }
isParsing = true
showParseOverlay = true
parseMessage = "正在提交知识点生成任务..."
do {
let result = try await KnowledgeSourceService.shared.generateKnowledge(kbId: kbId, sourceId: sourceId)
parseMessage = result.message
latestKnowledgeTask = result.asTaskStatus
ZXToastManager.shared.success(result.message)
restartKnowledgeTaskPollingIfNeeded()
} catch {
parseMessage = "生成失败: \(error.localizedDescription)"
ZXToastManager.shared.error(parseMessage)
}
isParsing = false
try? await Task.sleep(nanoseconds: 1_500_000_000)
if !isParsing { showParseOverlay = false }
}
private var generateKnowledgeMenuTitle: String {
switch latestKnowledgeTask?.status {
case "waiting_for_parse":
return "等待解析中"
case "queued":
return "已入队"
case "running":
return "生成中"
case "completed":
return "重新生成知识点"
case "failed":
return "重新生成知识点"
default:
return "生成知识点"
}
}
private var isKnowledgeTaskActive: Bool {
guard let status = latestKnowledgeTask?.status else { return false }
return ["waiting_for_parse", "queued", "running"].contains(status)
}
private func refreshKnowledgeTaskStatus() async {
guard let kbId = knowledgeBaseId else { return }
do {
latestKnowledgeTask = try await KnowledgeSourceService.shared.latestKnowledgeGenerationTask(
kbId: kbId,
sourceId: vm.materialId
)
if let latestKnowledgeTask {
parseMessage = latestKnowledgeTask.message
}
} catch {
print("[MaterialReader] refreshKnowledgeTaskStatus ERROR: \(error.localizedDescription)")
}
}
private func restartKnowledgeTaskPollingIfNeeded() {
stopKnowledgeTaskPolling()
guard isKnowledgeTaskActive else { return }
knowledgeTaskPollingTask = Task {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 3_000_000_000)
if Task.isCancelled { break }
await refreshKnowledgeTaskStatus()
if !isKnowledgeTaskActive { break }
}
knowledgeTaskPollingTask = nil
}
}
private func stopKnowledgeTaskPolling() {
knowledgeTaskPollingTask?.cancel()
knowledgeTaskPollingTask = nil
}
// MARK: - Unsupported
var unsupportedBody: some View {
VStack(spacing: 16) {
Image(systemName: "questionmark.folder").font(.system(size: 48)).foregroundColor(Color.zxF03)
Text("暂不支持此格式").font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0)
Text(vm.filePath).font(.system(size: 12)).foregroundColor(Color.zxF05)
}
}
}
// MARK: - DocumentBlock renderer (renders Rust-generated blocks via SwiftUI)
struct DocumentBlockView: View {
let block: DocumentBlock
var body: some View {
switch block {
case .heading(let id, let level, let text):
headingView(level: Int(level), text: text)
case .paragraph(let id, let text):
Text(text).font(.system(size: 15)).foregroundColor(Color.zxF0).lineSpacing(6)
case .list(let id, let ordered, let items):
listView(ordered: ordered, items: items)
case .codeBlock(let id, let language, let code):
codeView(language: language, code: code)
case .quote(let id, let text):
quoteView(text: text)
case .table(let id, let headers, let rows):
tableView(headers: headers, rows: rows)
case .imageBlock(let id, let src, let alt):
imageBlockView(src: src, alt: alt)
case .horizontalRule(let id):
Rectangle().fill(Color.zxBorder006).frame(height: 1)
}
}
func headingView(level: Int, text: String) -> some View {
let sizes: [CGFloat] = [0, 24, 20, 17, 15, 14, 13]
let size = level < sizes.count ? sizes[level] : 13
return Text(text)
.font(.system(size: size, weight: .bold)).foregroundColor(Color.zxF0)
.padding(.top, level <= 1 ? 8 : 4)
}
func listView(ordered: Bool, items: [String]) -> some View {
VStack(alignment: .leading, spacing: 6) {
ForEach(Array(items.enumerated()), id: \.offset) { i, item in
HStack(alignment: .top, spacing: 8) {
Text(ordered ? "\(i + 1)." : "")
.font(.system(size: 14, weight: ordered ? .medium : .regular))
.foregroundColor(Color.zxF04).frame(width: 20, alignment: ordered ? .trailing : .center)
Text(item).font(.system(size: 14)).foregroundColor(Color.zxF0)
}
}
}
}
func codeView(language: String?, code: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
if let lang = language {
Text(lang).font(.system(size: 11, weight: .medium)).foregroundColor(Color.zxF03)
.padding(.horizontal, 12).padding(.top, 8)
}
Text(code).font(.system(size: 13, design: .monospaced)).foregroundColor(Color.zxF0)
.padding(12).frame(maxWidth: .infinity, alignment: .leading)
}
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1))
}
func quoteView(text: String) -> some View {
HStack(spacing: 0) {
Rectangle().fill(Color.zxPrimary.opacity(0.3)).frame(width: 3)
Text(text).font(.system(size: 14)).foregroundColor(Color.zxF04).italic().padding(.leading, 12)
}
}
func tableView(headers: [String], rows: [[String]]) -> some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
ForEach(Array(headers.enumerated()), id: \.offset) { _, h in
Text(h).font(.system(size: 13, weight: .semibold)).foregroundColor(Color.zxF0)
.frame(maxWidth: .infinity, alignment: .leading).padding(8)
}
}.background(Color.zxFill004)
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
HStack(spacing: 0) {
ForEach(Array(row.enumerated()), id: \.offset) { _, cell in
Text(cell).font(.system(size: 13)).foregroundColor(Color.zxF0)
.frame(maxWidth: .infinity, alignment: .leading).padding(8)
}
}
}
}
.background(Color.zxSurface).clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.zxBorder008, lineWidth: 1))
}
func imageBlockView(src: String, alt: String?) -> some View {
VStack(spacing: 6) {
if let url = URL(string: src), src.hasPrefix("http") {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img): img.resizable().scaledToFit().clipShape(RoundedRectangle(cornerRadius: 10))
case .failure: Rectangle().fill(Color.zxFill004).frame(height: 160).overlay(Image(systemName: "photo").foregroundColor(Color.zxF03))
default: ProgressView().frame(height: 160)
}
}
} else {
Rectangle().fill(Color.zxFill004).frame(height: 120)
.overlay(VStack(spacing: 4) { Image(systemName: "photo"); Text(src).font(.system(size: 11)) }.foregroundColor(Color.zxF03))
}
if let alt = alt { Text(alt).font(.system(size: 12)).foregroundColor(Color.zxF04) }
}
}
}
// MARK: - MaterialType display name
extension MaterialType {
var displayName: String {
switch self {
case .markdown: "Markdown"
case .text: "纯文本"
case .image: "图片"
case .pdf: "PDF"
case .word: "Word 文档"
case .excel: "Excel 表格"
case .powerPoint: "PowerPoint"
case .epub: "EPUB"
case .unknown: "未知格式"
}
}
}
// MARK: - QuickLook wrapper
struct QuickLookPreview: UIViewControllerRepresentable {
let url: URL
func makeUIViewController(context: Context) -> QLPreviewController {
let c = QLPreviewController()
c.dataSource = context.coordinator
return c
}
func updateUIViewController(_ uiViewController: QLPreviewController, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(url: url) }
class Coordinator: NSObject, QLPreviewControllerDataSource {
let url: URL
init(url: URL) { self.url = url }
func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 }
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { url as QLPreviewItem }
}
}
// MARK: - Scroll tracking
private struct ScrollOffsetKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() }
}
// MARK: - Helpers
private func formatDuration(_ seconds: Int) -> String {
if seconds < 60 { return "\(seconds)s" }
if seconds < 3600 { return "\(seconds / 60)m" }
return "\(seconds / 3600)h\(String(format: "%02d", (seconds % 3600) / 60))m"
}
private func formatFileSize(_ bytes: UInt64) -> String {
if bytes < 1024 { return "\(bytes) B" }
if bytes < 1024 * 1024 { return String(format: "%.1f KB", Double(bytes) / 1024) }
return String(format: "%.1f MB", Double(bytes) / (1024 * 1024))
}