- MaterialReaderViewModel.filePath: let → var for runtime updates - Set vm.filePath to downloaded temp path before vm.load() - Remove unused downloadedFilePath/resolvedPath Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
796 lines
33 KiB
Swift
796 lines
33 KiB
Swift
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 {
|
||
@StateObject private var vm: MaterialReaderViewModel
|
||
@Binding var showQuickLook: Bool
|
||
@Binding var showNoteSheet: Bool
|
||
@State private var scrollProgress: CGFloat = 0
|
||
@State private var hasRestoredPosition = false
|
||
@State private var restoreBlockId: String?
|
||
@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?
|
||
|
||
@Environment(\.scenePhase) private var scenePhase
|
||
@State private var isParsing = false
|
||
@State private var parseMessage = ""
|
||
private let title: String
|
||
private let knowledgeBaseId: String?
|
||
|
||
// V2 session manager (primary) + V1 collector (fallback)
|
||
private let sessionManager = ReadingRuntimeSessionManager.shared
|
||
private let collector = ReadingEventCollector.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 = "", 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))
|
||
}
|
||
|
||
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 vm.load() } }
|
||
.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: 16) {
|
||
Button {
|
||
markAsRead()
|
||
} label: {
|
||
Image(systemName: isMarkedRead ? "checkmark.circle.fill" : "checkmark.circle")
|
||
.font(.system(size: 16))
|
||
.foregroundColor(isMarkedRead ? Color.green : Color.zxF05)
|
||
}
|
||
|
||
NavigationLink(value: Route.aiChat(context: ChatEntryContext(
|
||
scopeType: .material,
|
||
scopeId: vm.materialId,
|
||
scopeName: title,
|
||
parentKnowledgeBaseId: knowledgeBaseId,
|
||
createdFrom: "material_reader"
|
||
))) {
|
||
Image(systemName: "bubble.left.and.bubble.right")
|
||
.font(.system(size: 16))
|
||
.foregroundColor(Color.zxF05)
|
||
}
|
||
|
||
if let kbId = knowledgeBaseId {
|
||
NavigationLink(value: Route.materialDetail(
|
||
knowledgeBaseId: kbId,
|
||
fileName: title.isEmpty ? "资料" : title,
|
||
fileType: vm.materialType,
|
||
fileSize: 0,
|
||
filePath: vm.filePath,
|
||
uploadDate: nil
|
||
)) {
|
||
Image(systemName: "info.circle")
|
||
.font(.system(size: 16))
|
||
.foregroundColor(Color.zxF05)
|
||
}
|
||
|
||
NavigationLink(value: Route.learningSession(
|
||
taskTitle: title.isEmpty ? "学习资料" : title,
|
||
taskType: "study",
|
||
taskColorHex: "#3D7FFB"
|
||
)) {
|
||
Image(systemName: "arrow.triangle.2.circlepath")
|
||
.font(.system(size: 16))
|
||
.foregroundColor(Color.zxF05)
|
||
}
|
||
|
||
Button {
|
||
Task { await triggerParse(kbId: kbId, sourceId: vm.materialId) }
|
||
} label: {
|
||
Image(systemName: "brain.head.profile")
|
||
.font(.system(size: 16))
|
||
.foregroundColor(Color.zxF05)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.overlay(alignment: .bottom) {
|
||
if isParsing {
|
||
HStack(spacing: 8) {
|
||
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 {
|
||
if vm.filePath.isEmpty || !FileManager.default.fileExists(atPath: vm.filePath) {
|
||
if let kbId = knowledgeBaseId {
|
||
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)")
|
||
}
|
||
}
|
||
}
|
||
await vm.load()
|
||
}
|
||
.sheet(isPresented: $showQuickLook) {
|
||
QuickLookPreview(url: URL(fileURLWithPath: vm.filePath))
|
||
}
|
||
.sheet(isPresented: $showNoteSheet) {
|
||
QuickNoteSheet(
|
||
materialId: vm.materialId,
|
||
materialName: vm.materialType.displayName,
|
||
anchor: buildAnchor()
|
||
)
|
||
}
|
||
.onAppear {
|
||
openReadingSession()
|
||
hasRestoredPosition = false
|
||
}
|
||
.onDisappear {
|
||
closeReadingSession()
|
||
}
|
||
.onChange(of: scenePhase) { _, newPhase in
|
||
switch newPhase {
|
||
case .background:
|
||
if isV2Active {
|
||
sessionManager.pause()
|
||
print("[READER] Scene → background, session paused")
|
||
}
|
||
// Flush: export → enqueue
|
||
Task {
|
||
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: vm.loadingState) { _, newState in
|
||
if newState == .loaded, !hasRestoredPosition {
|
||
restorePosition()
|
||
hasRestoredPosition = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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()
|
||
}
|
||
.onChange(of: restoreBlockId) { _, target in
|
||
if let id = target {
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||
withAnimation { scrollProxy.scrollTo(id, anchor: .top) }
|
||
}
|
||
restoreBlockId = nil
|
||
}
|
||
}
|
||
.scrollIndicators(.hidden)
|
||
}
|
||
}
|
||
|
||
// MARK: - Reading Session Lifecycle
|
||
|
||
private func openReadingSession() {
|
||
// Prevent duplicate open
|
||
guard !isV2Active else {
|
||
print("[READER] V2 session already active, skipping open")
|
||
return
|
||
}
|
||
|
||
let targetType = knowledgeBaseId != nil ? "knowledge_source" : "temporary_file"
|
||
let context = ReadingMaterialContext(
|
||
readingTargetType: knowledgeBaseId != nil ? .knowledgeSource : .temporaryFile,
|
||
materialId: vm.materialId,
|
||
knowledgeBaseId: knowledgeBaseId,
|
||
title: title.isEmpty ? nil : title
|
||
)
|
||
|
||
// V2 primary
|
||
if let _ = try? sessionManager.openMaterial(context) {
|
||
print("[READER] V2 session opened — materialId=\(vm.materialId)")
|
||
} else {
|
||
print("[READER] V2 unavailable, falling back to V1")
|
||
collector.open(materialId: vm.materialId)
|
||
}
|
||
|
||
// Query reading progress from API
|
||
Task {
|
||
do {
|
||
let progress = try await readingAPI.getReadingProgress(materialId: vm.materialId, targetType: targetType)
|
||
await MainActor.run {
|
||
readingStatus = progress.status
|
||
readingProgressSeconds = progress.totalActiveSeconds
|
||
if progress.isMarkedRead { isMarkedRead = true; MarkedReadStore.shared.mark(vm.materialId) }
|
||
if let lp = progress.lastPosition {
|
||
apiRestorePosition = ReadingPositionAdapter.fromValue(lp)
|
||
}
|
||
}
|
||
} catch {
|
||
// 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)")
|
||
}
|
||
}
|
||
}
|
||
|
||
private func closeReadingSession() {
|
||
// V2 primary
|
||
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")
|
||
return
|
||
}
|
||
// V1 fallback
|
||
if let lastPos = collector.lastPosition {
|
||
positionStore.save(materialId: vm.materialId, position: lastPos)
|
||
}
|
||
_ = collector.close(materialId: vm.materialId)
|
||
print("[READER] V1 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)
|
||
if isV2Active {
|
||
sessionManager.markAsRead()
|
||
} else {
|
||
collector.markAsRead(materialId: vm.materialId)
|
||
}
|
||
}
|
||
|
||
/// Build a NoteAnchor from the current scroll position (for quick note).
|
||
private func buildAnchor() -> NoteAnchor? {
|
||
let pos = sessionManager.lastPosition ?? collector.lastPosition
|
||
guard let pos else { return nil }
|
||
return createNoteAnchor(materialId: vm.materialId, position: pos)
|
||
}
|
||
|
||
/// Restore saved reading position on re-entry.
|
||
/// Priority: API lastPosition > local positionStore > session lastPosition
|
||
private func restorePosition() {
|
||
// If API already fetched a position, use it immediately
|
||
if let apiPos = apiRestorePosition {
|
||
applyPosition(apiPos)
|
||
return
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
private func applyPosition(_ pos: ReadingPosition) {
|
||
switch pos {
|
||
case .markdown(let blockId, _):
|
||
restoreBlockId = blockId
|
||
case .text(let lineNumber, _):
|
||
let idx = Int(lineNumber) - 1
|
||
if idx >= 0, idx < vm.blocks.count {
|
||
restoreBlockId = ReadingPositionAdapter.blockId(from: vm.blocks[idx])
|
||
}
|
||
case .pdf, .image, .epub, .unknown:
|
||
break
|
||
}
|
||
}
|
||
|
||
/// Report current scroll position to the active session manager.
|
||
private func reportScrollPosition() {
|
||
guard !vm.blocks.isEmpty 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)
|
||
} else {
|
||
// V1 fallback
|
||
collector.updatePosition(materialId: vm.materialId, position: 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 triggerParse(kbId: String, sourceId: String) async {
|
||
guard !isParsing else { return }
|
||
isParsing = true
|
||
parseMessage = "正在整理知识点..."
|
||
do {
|
||
let result = try await KnowledgeSourceService.shared.parse(kbId: kbId, sourceId: sourceId)
|
||
parseMessage = "知识点整理任务已提交"
|
||
for _ in 0..<60 {
|
||
try await Task.sleep(nanoseconds: 2_000_000_000)
|
||
let status = try await DocumentImportService.shared.getStatus(id: result.jobId ?? result.id ?? "")
|
||
if status.status == "completed" { parseMessage = "知识点整理完成"; break }
|
||
else if status.status == "failed" { parseMessage = "整理失败"; break }
|
||
else { parseMessage = "整理中... \(status.progress ?? 0)%" }
|
||
}
|
||
} catch {
|
||
parseMessage = "整理失败: \(error.localizedDescription)"
|
||
}
|
||
isParsing = false
|
||
}
|
||
|
||
// 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))
|
||
}
|