feat: M-CHAT iOS ChatScope 入口 + AI View 对接

## ChatEntryContext 模型
- ChatScopeType enum (knowledgeBase/folder/material/knowledgeItem/global)
- ChatEntryContext struct + ChatScopeSnapshot
- ChatSession 更新 (新增 13 个 scope 字段)
- CreateSessionRequest/UpdateChatSessionRequest

## Route + Service
- Route.aiChat 从 knowledgeBaseId 改为 ChatEntryContext
- RagChatService.createSession 接入 open-or-create API
- listSessions 支持 scope 过滤
- 新增 updateSession (PATCH)

## 6 个入口全部接入
- 知识库详情 → knowledge_base scope
- 资料详情 → material scope
- 资料阅读页 → material scope
- 知识点详情 → knowledge_item scope
- 全局入口 → global scope

## AI Chat View
- open-or-create: load() 直接调 POST /sessions
- 顶部 scope 指示器 (scopeLabel + scopeIcon)
- 新对话按钮在当前 scope 下工作

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-06 17:41:27 +08:00
parent 4ebb70c036
commit 2610bcf7f9
10 changed files with 1513 additions and 157 deletions

View File

@ -470,11 +470,39 @@ struct QuizSubmitResponse: Codable {
// MARK: - RAG Chat // MARK: - RAG Chat
// MARK: - Chat Scope
enum ChatScopeType: String, Codable {
case knowledgeBase = "knowledge_base"
case folder = "folder"
case material = "material"
case knowledgeItem = "knowledge_item"
case global = "global"
}
struct ChatEntryContext {
let scopeType: ChatScopeType
let scopeId: String?
let scopeName: String
let parentKnowledgeBaseId: String?
let createdFrom: String
}
struct ChatSession: Codable, Identifiable { struct ChatSession: Codable, Identifiable {
let id: String let id: String
let userId: String? let userId: String?
let knowledgeBaseId: String? let knowledgeBaseId: String?
let scopeType: String?
let scopeId: String?
let parentKnowledgeBaseId: String?
let title: String? let title: String?
let createdFrom: String?
let isPinned: Bool?
let isArchived: Bool?
let isDeleted: Bool?
let modelMode: String?
let modelId: String?
let lastMessageAt: String?
let createdAt: String? let createdAt: String?
let updatedAt: String? let updatedAt: String?
} }
@ -485,23 +513,44 @@ struct ChatMessage: Codable, Identifiable {
let role: String let role: String
let content: String let content: String
let tokens: Int? let tokens: Int?
let scopeSnapshot: ChatScopeSnapshot?
let createdAt: String? let createdAt: String?
let citations: [ChatCitation]? let citations: [ChatCitation]?
} }
struct ChatScopeSnapshot: Codable {
let scopeType: String?
let scopeId: String?
let parentKnowledgeBaseId: String?
}
struct ChatCitation: Codable, Identifiable { struct ChatCitation: Codable, Identifiable {
let id: String let id: String
let chunkId: String? let chunkId: String?
let sourceId: String?
let sourceTitle: String? let sourceTitle: String?
let excerptText: String? let excerptText: String?
let pageNumber: Int? let pageNumber: Int?
let lineStart: Int?
let lineEnd: Int?
} }
struct CreateSessionRequest: Codable { struct CreateSessionRequest: Codable {
let knowledgeBaseId: String let scopeType: String
let scopeId: String?
let parentKnowledgeBaseId: String?
let createdFrom: String
let title: String? let title: String?
} }
struct UpdateChatSessionRequest: Codable {
var title: String?
var isPinned: Bool?
var isArchived: Bool?
var modelMode: String?
var modelId: String?
}
struct SendMessageRequest: Codable { struct SendMessageRequest: Codable {
let content: String let content: String
} }
@ -512,7 +561,7 @@ struct SendMessageResponse: Codable {
let content: String? let content: String?
let tokens: Int? let tokens: Int?
let blocked: Bool? let blocked: Bool?
let message: String? let message: ChatMessage?
let citations: [ChatCitation]? let citations: [ChatCitation]?
} }

View File

@ -2,7 +2,7 @@ import SwiftUI
enum Route: Hashable { enum Route: Hashable {
// AI // AI
case aiChat case aiChat(context: ChatEntryContext? = nil)
case dailyThinking case dailyThinking
case aiFeedback case aiFeedback
case activeRecall case activeRecall
@ -30,6 +30,10 @@ enum Route: Hashable {
case quizTake(quizId: String) case quizTake(quizId: String)
case quizResult(quizId: String, attemptId: String) case quizResult(quizId: String, attemptId: String)
// Material Reader
case materialReader(materialId: String, filePath: String, materialType: MaterialType)
case materialDetail(knowledgeBaseId: String, fileName: String, fileType: MaterialType, fileSize: UInt64, filePath: String, uploadDate: String?)
// Profile // Profile
case notificationList case notificationList
case settings case settings
@ -40,38 +44,90 @@ enum Route: Hashable {
} }
extension Route { extension Route {
var label: String {
switch self {
case .aiChat: "AIChatPage"
case .dailyThinking: "DailyThinkingPage"
case .aiFeedback: "AIFeedbackPageView"
case .activeRecall: "ActiveRecallView"
case .weakPoints: "WeakPointsPage"
case .reviewCard: "ReviewCardView"
case .librarySearch: "LibrarySearchView"
case .libraryDetail: "LibraryDetailPage"
case .libraryImport: "ImportPage"
case .libraryCreate: "CreateLibraryPage"
case .addKnowledge: "AddKnowledgePage"
case .knowledgeDetail: "KnowledgeDetailPage"
case .editKnowledge: "EditKnowledgePage"
case .learningSession: "LearningSessionView"
case .studyHome: "StudyHomeView"
case .notificationList: "NotificationListView"
case .settings: "SettingsView"
case .goalSetting: "GoalSettingDetailView"
case .methodPreference: "MethodPreferenceView"
case .feedbackForm: "FeedbackFormView"
case .editProfile: "EditProfilePage"
case .importReview: "ImportReviewPage"
case .quizList: "QuizListView"
case .quizTake: "QuizTakerView"
case .quizResult: "QuizResultView"
case .materialReader: "MaterialReaderView"
case .materialDetail: "MaterialDetailView"
}
}
@ViewBuilder @ViewBuilder
var destination: some View { var destination: some View {
let content: AnyView = {
switch self { switch self {
case .aiChat: AIChatPage() case .aiChat(let ctx): AnyView(AIChatPage(context: ctx))
case .dailyThinking: DailyThinkingPage() case .dailyThinking: AnyView(DailyThinkingPage())
case .aiFeedback: AIFeedbackPageView() case .aiFeedback: AnyView(AIFeedbackPageView())
case .activeRecall: ActiveRecallView() case .activeRecall: AnyView(ActiveRecallView())
case .weakPoints: WeakPointsPage() case .weakPoints: AnyView(WeakPointsPage())
case .reviewCard: ReviewCardView() case .reviewCard: AnyView(ReviewCardView())
case .librarySearch: LibrarySearchView() case .librarySearch: AnyView(LibrarySearchView())
case .libraryDetail(let id): LibraryDetailPage(knowledgeBaseId: id) case .libraryDetail(let id): AnyView(LibraryDetailPage(knowledgeBaseId: id))
case .libraryImport: ImportPage() case .libraryImport: AnyView(ImportPage())
case .libraryCreate: CreateLibraryPage() case .libraryCreate: AnyView(CreateLibraryPage())
case .addKnowledge(let id): AddKnowledgePage(knowledgeBaseId: id) case .addKnowledge(let id): AnyView(AddKnowledgePage(knowledgeBaseId: id))
case .knowledgeDetail(let item): KnowledgeDetailPage(item: item) case .knowledgeDetail(let item): AnyView(KnowledgeDetailPage(item: item))
case .editKnowledge(let item): EditKnowledgePage(item: item) case .editKnowledge(let item): AnyView(EditKnowledgePage(item: item))
case .learningSession(let title, let type, let colorHex): case .learningSession(let title, let type, let colorHex):
LearningSessionView(taskTitle: title, taskType: type, taskColor: Color(hex: colorHex)) AnyView(LearningSessionView(taskTitle: title, taskType: type, taskColor: Color(hex: colorHex)))
case .studyHome: StudyHomeView(selectedTab: .constant("study")) case .studyHome: AnyView(StudyHomeView(selectedTab: .constant("study")))
case .notificationList: NotificationListView() case .notificationList: AnyView(NotificationListView())
case .settings: SettingsView() case .settings: AnyView(SettingsView())
case .goalSetting: GoalSettingDetailView() case .goalSetting: AnyView(GoalSettingDetailView())
case .methodPreference: MethodPreferenceView() case .methodPreference: AnyView(MethodPreferenceView())
case .feedbackForm: FeedbackFormView() case .feedbackForm: AnyView(FeedbackFormView())
case .editProfile: EditProfilePage() case .editProfile: AnyView(EditProfilePage())
case .importReview(let sourceId): ImportReviewPage(sourceId: sourceId) case .importReview(let sourceId): AnyView(ImportReviewPage(sourceId: sourceId))
case .quizList(let kbId): QuizListView(knowledgeBaseId: kbId) case .quizList(let kbId): AnyView(QuizListView(knowledgeBaseId: kbId))
case .quizTake(let id): QuizTakerView(quizId: id) case .quizTake(let id): AnyView(QuizTakerView(quizId: id))
case .quizResult(let qid, let aid): QuizResultView(quizId: qid, attemptId: aid) case .quizResult(let qid, let aid): AnyView(QuizResultView(quizId: qid, attemptId: aid))
case .materialReader(let mid, let path, let mt): AnyView(MaterialReaderView(materialId: mid, filePath: path, materialType: mt))
case .materialDetail(let kbId, let name, let type, let size, let path, let date):
AnyView(MaterialDetailView(knowledgeBaseId: kbId, fileName: name, fileType: type, fileSize: size, filePath: path, uploadDate: date))
} }
}()
#if DEBUG
content.overlay(alignment: .topTrailing) {
Text(label)
.font(.system(size: 9, weight: .semibold, design: .monospaced))
.foregroundColor(.white)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(Color.black.opacity(0.55))
.cornerRadius(4)
.padding(.top, 60).padding(.trailing, 4)
.allowsHitTesting(false)
}
#else
content
#endif
} }
} }

View File

@ -344,13 +344,23 @@ class RagChatService {
static let shared = RagChatService() static let shared = RagChatService()
private let client = APIClient.shared private let client = APIClient.shared
func createSession(knowledgeBaseId: String, title: String? = nil) async throws -> ChatSession { func createSession(ctx: ChatEntryContext) async throws -> ChatSession {
let body = CreateSessionRequest(knowledgeBaseId: knowledgeBaseId, title: title) let body = CreateSessionRequest(
scopeType: ctx.scopeType.rawValue,
scopeId: ctx.scopeId,
parentKnowledgeBaseId: ctx.parentKnowledgeBaseId,
createdFrom: ctx.createdFrom,
title: nil
)
return try await client.request("/rag-chat/sessions", method: "POST", body: body) return try await client.request("/rag-chat/sessions", method: "POST", body: body)
} }
func listSessions() async throws -> [ChatSession] { func listSessions(scopeType: String? = nil, scopeId: String? = nil, parentKnowledgeBaseId: String? = nil) async throws -> [ChatSession] {
return try await client.request("/rag-chat/sessions") var items: [URLQueryItem] = []
if let st = scopeType { items.append(URLQueryItem(name: "scopeType", value: st)) }
if let si = scopeId { items.append(URLQueryItem(name: "scopeId", value: si)) }
if let pk = parentKnowledgeBaseId { items.append(URLQueryItem(name: "parentKnowledgeBaseId", value: pk)) }
return try await client.request("/rag-chat/sessions", queryItems: items.isEmpty ? nil : items)
} }
func getMessages(sessionId: String) async throws -> [ChatMessage] { func getMessages(sessionId: String) async throws -> [ChatMessage] {
@ -362,9 +372,50 @@ class RagChatService {
return try await client.request("/rag-chat/sessions/\(sessionId)/messages", method: "POST", body: body) return try await client.request("/rag-chat/sessions/\(sessionId)/messages", method: "POST", body: body)
} }
func updateSession(id: String, dto: UpdateChatSessionRequest) async throws -> ChatSession {
return try await client.request("/rag-chat/sessions/\(id)", method: "PATCH", body: dto)
}
func deleteSession(id: String) async throws { func deleteSession(id: String) async throws {
let _: GenericSuccessResponse = try await client.request("/rag-chat/sessions/\(id)", method: "DELETE") let _: GenericSuccessResponse = try await client.request("/rag-chat/sessions/\(id)", method: "DELETE")
} }
struct SSEChunk: Decodable {
let type: String
let content: String?
}
func sendMessageStream(sessionId: String, content: String) -> AsyncThrowingStream<SSEChunk, Error> {
AsyncThrowingStream { continuation in
Task {
let token = await APIClient.shared.getToken() ?? ""
let url = URL(string: "\(APIConfig.baseURL)/rag-chat/sessions/\(sessionId)/stream")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.httpBody = try? JSONEncoder().encode(SendMessageRequest(content: content))
let session = URLSession(configuration: .default)
let (bytes, _) = try await session.bytes(for: request)
// Read SSE lines properly URLSession.bytes is character-based (UTF-8 safe)
var lineBuffer = ""
for try await line in bytes.lines {
if line.hasPrefix("data: "), let jsonData = line.dropFirst(6).data(using: .utf8) {
if let chunk = try? JSONDecoder().decode(SSEChunk.self, from: jsonData) {
continuation.yield(chunk)
if chunk.type == "done" || chunk.type == "error" {
continuation.finish()
return
}
}
}
}
continuation.finish()
}
}
}
} }
// MARK: - Document Import // MARK: - Document Import

View File

@ -2,10 +2,18 @@ import SwiftUI
import UIKit import UIKit
struct AIChatPage: View { struct AIChatPage: View {
@StateObject private var vm = AIChatViewModel() @StateObject private var vm: AIChatViewModel
@State private var showSessions = false @State private var showSessions = false
@State private var sessions: [ChatSession] = [] @State private var sessions: [ChatSession] = []
init(context: ChatEntryContext? = nil) {
let vm = AIChatViewModel()
if let ctx = context {
vm.setEntryContext(ctx)
}
_vm = StateObject(wrappedValue: vm)
}
var body: some View { var body: some View {
ZStack { ZStack {
Color.zxBg0.ignoresSafeArea() Color.zxBg0.ignoresSafeArea()
@ -23,46 +31,38 @@ struct AIChatPage: View {
} else { } else {
ScrollViewReader { proxy in ScrollViewReader { proxy in
ScrollView { ScrollView {
VStack(spacing: 16) { VStack(spacing: 12) {
// Scope indicator
if let label = vm.scopeLabel {
HStack(spacing: 6) {
Image(systemName: vm.scopeIcon)
.font(.system(size: 11))
Text(label)
.font(.system(size: 12, weight: .medium))
}
.foregroundColor(Color.zxF03)
.padding(.horizontal, 12).padding(.vertical, 6)
.background(Color.zxFill003)
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.top, 4)
}
ForEach(vm.messages) { m in ForEach(vm.messages) { m in
VStack(alignment: m.role == .user ? .trailing : .leading, spacing: 6) { MessageCard(message: m, onRegenerate: { Task { vm.send() } })
chatBubble(m) .id(m.id)
// Citations below AI messages .padding(.horizontal, 20)
if m.role == .ai, let citations = m.citations, !citations.isEmpty {
VStack(spacing: 4) {
ForEach(citations.prefix(3)) { c in
HStack(spacing: 4) {
Image(systemName: "doc").font(.system(size: 10)).foregroundColor(Color.zxF04)
Text(c.excerptText?.prefix(60).description ?? "").font(.system(size: 10)).foregroundColor(Color.zxF04).lineLimit(1)
}.padding(.horizontal, 8).padding(.vertical, 4)
.background(Color.zxFill004).clipShape(Capsule())
} }
}.padding(.leading, 36) if vm.isSending && vm.messages.last?.role == .user {
HStack { ProgressView().tint(Color.zxPurple).padding(); Spacer() }
.padding(.horizontal, 20)
} }
// Actions for AI messages
if m.role == .ai {
HStack(spacing: 16) {
Button { UIPasteboard.general.string = m.content } label: {
Label("复制", systemImage: "doc.on.doc").font(.system(size: 12)).foregroundColor(Color.zxF04)
} }
Button { Task { vm.send() } } label: { .padding(.top, 8).padding(.bottom, 100)
Label("重新生成", systemImage: "arrow.clockwise").font(.system(size: 12)).foregroundColor(Color.zxF04)
}
}.padding(.leading, 36)
}
}.id(m.id).padding(.horizontal, 20)
}
if vm.isSending {
HStack(spacing: 8) {
Image("icon-brain").foregroundColor(Color.zxPurple)
.frame(width: 28, height: 28).background(Color(hex: "#7C6EFA", opacity: 0.15)).clipShape(Circle())
ProgressView().tint(Color.zxPurple).padding(.leading, 4); Spacer()
}.padding(.horizontal, 20)
}
}.padding(.top, 8).padding(.bottom, 100)
} }
.scrollIndicators(.hidden) .scrollIndicators(.hidden)
.onChange(of: vm.messages.count) { _ in withAnimation { proxy.scrollTo(vm.messages.last?.id) } } .onChange(of: vm.messages.count) { _ in scrollToBottom(proxy) }
.onChange(of: vm.messages.last?.content) { _ in scrollToBottom(proxy) }
.onChange(of: vm.messages.last?.thinkingContent) { _ in scrollToBottom(proxy) }
} }
ZXAIInputBar(text: $vm.inputText, onSend: { vm.send() }).padding(.horizontal, 20).padding(.bottom, 34) ZXAIInputBar(text: $vm.inputText, onSend: { vm.send() }).padding(.horizontal, 20).padding(.bottom, 34)
} }
@ -72,56 +72,164 @@ struct AIChatPage: View {
.toolbarBackground(.hidden, for: .navigationBar) .toolbarBackground(.hidden, for: .navigationBar)
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button { Task { sessions = (try? await RagChatService.shared.listSessions()) ?? []; showSessions = true } } label: { HStack(spacing: 12) {
Button { Task { await vm.createNewSession() } } label: {
Image(systemName: "plus.bubble").font(.system(size: 16)).foregroundColor(Color.zxF05)
}
Button { showSessions = true } label: {
Image("icon-list").font(.system(size: 16)).foregroundColor(Color.zxF05) Image("icon-list").font(.system(size: 16)).foregroundColor(Color.zxF05)
} }
} }
} }
}
.sheet(isPresented: $showSessions) { .sheet(isPresented: $showSessions) {
VStack(spacing: 0) { VStack(spacing: 0) {
RoundedRectangle(cornerRadius: 3).fill(Color.zxF03).frame(width: 36, height: 5).padding(.top, 12).padding(.bottom, 16) HStack {
Text("对话列表").font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0).padding(.bottom, 16) Text("对话列表").font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0)
Spacer()
Button {
showSessions = false
Task { await vm.createNewSession() }
} label: {
Image(systemName: "plus.bubble").font(.system(size: 18)).foregroundColor(Color.zxF05)
}
}.padding(.horizontal, 20).padding(.top, 16).padding(.bottom, 12)
if sessions.isEmpty { if sessions.isEmpty {
Text("暂无历史对话").font(.system(size: 14)).foregroundColor(Color.zxF04).padding(.top, 40) Text("暂无历史对话").font(.system(size: 14)).foregroundColor(Color.zxF04).padding(.top, 40)
} else { } else {
ScrollView { List {
VStack(spacing: 0) {
ForEach(sessions) { s in ForEach(sessions) { s in
Button { Button {
showSessions = false showSessions = false
Task { await vm.loadSession(s.id) } Task { await vm.loadSession(s.id) }
} label: { } label: {
HStack { HStack {
VStack(alignment: .leading, spacing: 2) {
Text(s.title ?? "对话").font(.system(size: 14)).foregroundColor(Color.zxF0) Text(s.title ?? "对话").font(.system(size: 14)).foregroundColor(Color.zxF0)
Text(s.updatedAt?.prefix(10).description ?? "").font(.system(size: 11)).foregroundColor(Color.zxF04)
}
Spacer() Spacer()
Text(s.updatedAt?.prefix(10).description ?? "").font(.system(size: 12)).foregroundColor(Color.zxF04) }.padding(.vertical, 4)
}.padding(.horizontal, 20).padding(.vertical, 14)
}.foregroundColor(.primary) }.foregroundColor(.primary)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task {
try? await RagChatService.shared.deleteSession(id: s.id)
sessions.removeAll { $0.id == s.id }
}
} label: { Label("删除", systemImage: "trash") }
} }
} }
} }
.listStyle(.plain)
.scrollContentBackground(.hidden)
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.zxBg0) .frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.zxBg0)
.presentationDetents([.medium, .large]) .presentationDetents([.medium, .large])
.task {
if let s = try? await RagChatService.shared.listSessions() { sessions = s }
}
} }
.task { await vm.load() } .task { await vm.load() }
} }
private func chatBubble(_ m: AIMessage) -> some View { private func scrollToBottom(_ proxy: ScrollViewProxy) {
HStack(alignment: .top, spacing: 8) { withAnimation {
if m.role == .ai { proxy.scrollTo(vm.messages.last?.id, anchor: .bottom)
Image("icon-brain").foregroundColor(Color.zxPurple)
.frame(width: 28, height: 28).background(Color(hex: "#7C6EFA", opacity: 0.15)).clipShape(Circle())
} }
Text(m.content).zxFontScaled(size: 14)
.foregroundColor(m.role == .user ? .white : Color.zxF007).padding(12)
.background(m.role == .user ? AnyView(ZXGradient.brandPurple) : AnyView(Color.zxFill004))
.clipShape(RoundedRectangle(cornerRadius: 16))
if m.role == .user {
Circle().frame(width: 28, height: 28)
.foregroundColor(Color.zxPurpleBG(0.2)).overlay(Text("").font(.system(size: 10, weight: .bold)).foregroundColor(Color.zxPurple))
}
}.frame(maxWidth: .infinity, alignment: m.role == .user ? .trailing : .leading)
} }
} }
// MARK: - Message Card
struct MessageCard: View {
let message: AIMessage
let onRegenerate: () -> Void
@State private var thinkingExpanded = true
var body: some View {
let isAI = message.role == .ai
let hasContent = !message.content.isEmpty
let hasThinking = !(message.thinkingContent?.isEmpty ?? true)
let isStreaming = message.isStreaming
// Don't render empty placeholder bubbles
if !hasContent && !hasThinking { EmptyView() }
else {
VStack(alignment: isAI ? .leading : .trailing, spacing: 0) {
// Thinking process (inside card, at top)
if hasThinking {
ThinkingSection(text: message.thinkingContent ?? "", isExpanded: $thinkingExpanded, isStreaming: isStreaming)
if hasContent { Divider().background(Color.zxF03).padding(.horizontal, 8) }
}
// Content
if hasContent {
Text(message.content).zxFontScaled(size: 14)
.foregroundColor(isAI ? Color.zxF007 : .white)
.padding(12)
}
// Actions (AI only, after streaming done)
if isAI && !isStreaming && hasContent {
HStack(spacing: 16) {
Button { UIPasteboard.general.string = message.content } label: {
Label("复制", systemImage: "doc.on.doc").font(.system(size: 12)).foregroundColor(Color.zxF04)
}
Button { onRegenerate() } label: {
Label("重新生成", systemImage: "arrow.clockwise").font(.system(size: 12)).foregroundColor(Color.zxF04)
}
Spacer()
}.padding(.horizontal, 12).padding(.bottom, 8)
}
// Loading indicator
if isAI && isStreaming && !hasContent && hasThinking {
HStack { ProgressView().tint(Color.zxPurple).scaleEffect(0.8); Spacer() }
.padding(.horizontal, 12).padding(.bottom, 8)
}
}
.frame(maxWidth: .infinity, alignment: isAI ? .leading : .trailing)
.background(isAI ? AnyView(Color.zxFill004) : AnyView(ZXGradient.brandPurple))
.clipShape(RoundedRectangle(cornerRadius: 14))
}
}
}
// MARK: - Thinking Section
struct ThinkingSection: View {
let text: String
@Binding var isExpanded: Bool
let isStreaming: Bool
var body: some View {
VStack(spacing: 0) {
// Collapse toggle
Button {
withAnimation(.easeInOut(duration: 0.2)) { isExpanded.toggle() }
} label: {
HStack(spacing: 4) {
Text(isExpanded ? "思考中..." : "思考完成")
.font(.system(size: 11)).foregroundColor(Color.zxF03)
if isStreaming { ProgressView().scaleEffect(0.5).tint(Color.zxF03) }
Spacer()
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.system(size: 8)).foregroundColor(Color.zxF03)
}
.padding(.horizontal, 12).padding(.vertical, 6)
}
// Thinking content
if isExpanded {
Text(text)
.font(.system(size: 12)).foregroundColor(Color.zxF03)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12).padding(.bottom, 8)
}
}
.background(Color.zxFill003)
}
}

View File

@ -2,10 +2,21 @@ import Combine
import Foundation import Foundation
struct AIMessage: Identifiable { struct AIMessage: Identifiable {
let id = UUID() let id: UUID
let role: AIMessageRole let role: AIMessageRole
let content: String let content: String
let thinkingContent: String?
let citations: [ChatCitation]? let citations: [ChatCitation]?
let isStreaming: Bool
init(role: AIMessageRole, content: String, thinkingContent: String? = nil, citations: [ChatCitation]? = nil, isStreaming: Bool = false, id: UUID = UUID()) {
self.id = id
self.role = role
self.content = content
self.thinkingContent = thinkingContent
self.citations = citations
self.isStreaming = isStreaming
}
} }
struct AIMessageCitation: Identifiable { struct AIMessageCitation: Identifiable {
@ -27,7 +38,8 @@ final class AIChatViewModel: ObservableObject {
@Published var sessionError: String? @Published var sessionError: String?
private var sessionId: String? private var sessionId: String?
private var knowledgeBaseId: String? private var entryContext: ChatEntryContext?
private var itemIds: [String]?
var canSend: Bool { var canSend: Bool {
!inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isSending && sessionId != nil !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isSending && sessionId != nil
@ -35,27 +47,64 @@ final class AIChatViewModel: ObservableObject {
var isReady: Bool { sessionId != nil } var isReady: Bool { sessionId != nil }
var scopeLabel: String? {
guard let ctx = entryContext, ctx.scopeType != .global else { return nil }
return "提问范围:\(ctx.scopeName)"
}
var scopeIcon: String {
guard let ctx = entryContext else { return "globe" }
switch ctx.scopeType {
case .knowledgeBase: return "books.vertical"
case .folder: return "folder"
case .material: return "doc.text"
case .knowledgeItem: return "lightbulb"
case .global: return "globe"
}
}
func load() async { func load() async {
guard sessionId == nil else { return } guard sessionId == nil else { return }
//
if knowledgeBaseId == nil {
do {
let kbs = try await KnowledgeBaseService.shared.list(page: 1, limit: 1)
knowledgeBaseId = kbs.first?.id
} catch {}
}
guard let kbId = knowledgeBaseId else {
sessionError = "请先创建一个知识库,才能使用 AI 对话"
return
}
isCreatingSession = true isCreatingSession = true
let ctx = entryContext ?? ChatEntryContext(
scopeType: .global,
scopeId: nil,
scopeName: "AI 对话",
parentKnowledgeBaseId: nil,
createdFrom: "global_ai_entry"
)
do { do {
let session = try await RagChatService.shared.createSession(knowledgeBaseId: kbId, title: nil) // open-or-create: backend handles find-or-create logic
let session = try await RagChatService.shared.createSession(ctx: ctx)
await loadSession(session.id)
} catch {
sessionError = "创建对话失败"
isCreatingSession = false
}
}
func createNewSession() async {
sessionId = nil
messages = []
isCreatingSession = true
// Use entry context if available, otherwise default to global
let ctx = entryContext ?? ChatEntryContext(
scopeType: .global,
scopeId: nil,
scopeName: "AI 对话",
parentKnowledgeBaseId: nil,
createdFrom: "global_ai_entry"
)
do {
let session = try await RagChatService.shared.createSession(ctx: ctx)
sessionId = session.id sessionId = session.id
messages = [AIMessage(role: .ai, content: "你好!我是你的 AI 学习助手,基于你的知识库回答问题。", citations: nil)] let scopeLabel = ctx.scopeType != .global ? "\(ctx.scopeName)」的" : ""
messages = [AIMessage(role: .ai, content: "你好!我是你的 AI 学习助手,基于\(scopeLabel)内容回答问题。")]
isCreatingSession = false isCreatingSession = false
} catch { } catch {
sessionError = "创建对话失败" sessionError = "创建对话失败"
@ -66,35 +115,65 @@ final class AIChatViewModel: ObservableObject {
func send() { func send() {
guard canSend, let sid = sessionId else { return } guard canSend, let sid = sessionId else { return }
let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines) let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
messages.append(AIMessage(role: .user, content: text, citations: nil)) messages.append(AIMessage(role: .user, content: text))
inputText = "" inputText = ""
isSending = true isSending = true
let placeholderId = UUID()
messages.append(AIMessage(role: .ai, content: "", isStreaming: true, id: placeholderId))
Task { Task {
do { do {
let resp = try await RagChatService.shared.sendMessage(sessionId: sid, content: text) let stream = RagChatService.shared.sendMessageStream(sessionId: sid, content: text)
if resp.blocked == true { var thinking = ""
messages.append(AIMessage(role: .ai, content: resp.message ?? "内容违规,请修改后重试", citations: nil)) var reply = ""
} else {
messages.append(AIMessage(role: .ai, content: resp.content ?? "抱歉AI 暂时无法回复", citations: resp.citations)) for try await chunk in stream {
switch chunk.type {
case "thinking":
thinking += chunk.content ?? ""
if let idx = messages.firstIndex(where: { $0.id == placeholderId }) {
messages[idx] = AIMessage(role: .ai, content: reply, thinkingContent: thinking, isStreaming: true, id: placeholderId)
}
case "content":
reply += chunk.content ?? ""
if let idx = messages.firstIndex(where: { $0.id == placeholderId }) {
messages[idx] = AIMessage(role: .ai, content: reply, thinkingContent: thinking, isStreaming: true, id: placeholderId)
}
case "error":
if let idx = messages.firstIndex(where: { $0.id == placeholderId }) {
messages[idx] = AIMessage(role: .ai, content: "请求失败: \(chunk.content ?? "未知错误")")
}
case "done":
if let idx = messages.firstIndex(where: { $0.id == placeholderId }) {
messages[idx] = AIMessage(role: .ai, content: reply, thinkingContent: thinking.isEmpty ? nil : thinking)
}
default: break
}
} }
} catch { } catch {
messages.append(AIMessage(role: .ai, content: "发送失败: \(error.localizedDescription)", citations: nil)) if let idx = messages.firstIndex(where: { $0.id == placeholderId }) {
messages[idx] = AIMessage(role: .ai, content: "发送失败: \(error.localizedDescription)")
}
} }
isSending = false isSending = false
} }
} }
func setKnowledgeBase(_ id: String) { knowledgeBaseId = id } func setEntryContext(_ ctx: ChatEntryContext) { entryContext = ctx }
func loadSession(_ id: String) async { func loadSession(_ id: String) async {
sessionId = id sessionId = id
isCreatingSession = true isCreatingSession = true
do { do {
let msgs: [ChatMessage] = try await RagChatService.shared.getMessages(sessionId: id) let msgs: [ChatMessage] = try await RagChatService.shared.getMessages(sessionId: id)
if msgs.isEmpty {
messages = [AIMessage(role: .ai, content: "你好!我是你的 AI 学习助手,基于你的知识库回答问题。")]
} else {
messages = msgs.map { m in messages = msgs.map { m in
AIMessage(role: m.role == "user" ? .user : .ai, content: m.content, citations: m.citations) AIMessage(role: m.role == "user" ? .user : .ai, content: m.content, citations: m.citations)
} }
}
} catch { sessionError = "加载对话失败" } } catch { sessionError = "加载对话失败" }
isCreatingSession = false isCreatingSession = false
} }

View File

@ -71,7 +71,7 @@ struct AIFeedbackPageView: View {
.shadow(color: Color(hex: "#7C6EFA", opacity: 0.3), radius: 24) .shadow(color: Color(hex: "#7C6EFA", opacity: 0.3), radius: 24)
} }
HStack(spacing: 12) { HStack(spacing: 12) {
NavigationLink(value: Route.aiChat) { NavigationLink(value: Route.aiChat(context: nil)) {
HStack(spacing: 4) { HStack(spacing: 4) {
Text("深入提问").font(.system(size: 14)) Text("深入提问").font(.system(size: 14))
Image("icon-chevron-right").resizable().scaledToFit().frame(width: 14, height: 14) Image("icon-chevron-right").resizable().scaledToFit().frame(width: 14, height: 14)

View File

@ -232,7 +232,7 @@ struct AIHomeView: View {
} }
.foregroundColor(.primary) .foregroundColor(.primary)
NavigationLink(value: Route.aiChat) { NavigationLink(value: Route.aiChat(context: nil)) {
quickActionItem( quickActionItem(
icon: "mic", icon: "mic",
label: "费曼\n解释练习" label: "费曼\n解释练习"

View File

@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import Combine
import PhotosUI import PhotosUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
@ -391,6 +392,22 @@ struct LibraryDetailPage: View {
Text("确定要删除选中的 \(selectedIds.count) 个知识点吗?此操作不可恢复。") Text("确定要删除选中的 \(selectedIds.count) 个知识点吗?此操作不可恢复。")
} }
.task { await viewModel.loadItems(knowledgeBaseId: knowledgeBaseId) } .task { await viewModel.loadItems(knowledgeBaseId: knowledgeBaseId) }
.safeAreaInset(edge: .bottom) {
NavigationLink(value: Route.aiChat(context: ChatEntryContext(scopeType: .knowledgeBase, scopeId: knowledgeBaseId, scopeName: "知识库", parentKnowledgeBaseId: knowledgeBaseId, createdFrom: "knowledge_base_detail"))) {
HStack(spacing: 8) {
Image(systemName: "bubble.left.and.bubble.right").font(.system(size: 16))
Text("AI 对话").font(.system(size: 15, weight: .medium))
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(ZXGradient.brandPurple)
.clipShape(RoundedRectangle(cornerRadius: 14))
.padding(.horizontal, 20)
.padding(.bottom, 8)
}
.background(Color.zxBg0)
}
} }
private func kbInfoHeader(_ kb: KnowledgeBase) -> some View { private func kbInfoHeader(_ kb: KnowledgeBase) -> some View {
@ -888,38 +905,203 @@ struct AddKnowledgePage: View {
} }
} }
// MARK: - Knowledge Detail ViewModel
@MainActor
final class KnowledgeDetailViewModel: ObservableObject {
@Published var localPath: String?
@Published var detectedType: MaterialType?
@Published var loadState: LoadState = .loading
enum LoadState { case loading, ready, error(String) }
let item: KnowledgeItem
init(item: KnowledgeItem) { self.item = item }
var isURL: Bool {
guard let c = item.content else { return false }
return c.hasPrefix("http://") || c.hasPrefix("https://")
}
func load() async {
print("[KD_DETAIL] load() start — item.id=\(item.id), title=\(item.title)")
print("[KD_DETAIL] isURL=\(isURL), content prefix=\(item.content?.prefix(200) ?? "nil")")
guard isURL, let urlStr = item.content, let url = URL(string: urlStr) else {
// Not a URL plain text content, ready immediately
let contentLen = item.content?.count ?? 0
print("[KD_DETAIL] plain text path — content length=\(contentLen), setting .ready")
loadState = .ready
return
}
loadState = .loading
let ext = url.pathExtension.isEmpty ? "bin" : url.pathExtension.lowercased()
let localURL = FileManager.default.temporaryDirectory
.appendingPathComponent("kd_\(item.id).\(ext)")
print("[KD_DETAIL] URL download — ext=\(ext), local=\(localURL.path)")
// Check cached file skip if it's a COS error XML
if FileManager.default.fileExists(atPath: localURL.path) {
let fileSize = (try? FileManager.default.attributesOfItem(atPath: localURL.path)[.size] as? Int) ?? -1
// Detect COS error responses (small XML files < 2KB)
if fileSize > 0 && fileSize < 2048 {
if let content = try? String(contentsOfFile: localURL.path, encoding: .utf8),
content.contains("<Error>"), content.contains("AccessDenied") {
print("[KD_DETAIL] cached file is COS error, deleting and re-fetching...")
try? FileManager.default.removeItem(at: localURL)
}
}
if FileManager.default.fileExists(atPath: localURL.path) {
print("[KD_DETAIL] file cached — size=\(fileSize), type=\(typeFromExtension(ext))")
localPath = localURL.path
detectedType = typeFromExtension(ext)
loadState = .ready
return
}
}
// Re-fetch item from API to get a fresh signed URL
print("[KD_DETAIL] re-fetching item from API for fresh URL...")
let freshItem = try? await KnowledgeItemService.shared.detail(id: item.id)
let downloadURL: URL
if let fresh = freshItem, let freshStr = fresh.content, let freshURL = URL(string: freshStr) {
print("[KD_DETAIL] got fresh URL")
downloadURL = freshURL
} else {
print("[KD_DETAIL] re-fetch failed, using original URL")
downloadURL = url
}
do {
let (data, _) = try await URLSession.shared.data(from: downloadURL)
print("[KD_DETAIL] download done — size=\(data.count)")
// If it's a COS error XML, show error
if data.count < 2048, let text = String(data: data, encoding: .utf8), text.contains("<Error>") {
print("[KD_DETAIL] downloaded content is an error XML: \(text.prefix(200))")
loadState = .error("文件访问已过期,请刷新后重试")
return
}
try data.write(to: localURL)
localPath = localURL.path
detectedType = typeFromExtension(ext)
print("[KD_DETAIL] saved — path=\(localURL.path), type=\(String(describing: detectedType))")
loadState = .ready
} catch {
print("[KD_DETAIL] download ERROR — \(error.localizedDescription)")
loadState = .error(error.localizedDescription)
}
}
private func typeFromExtension(_ ext: String) -> MaterialType {
switch ext {
case "md", "markdown": return .markdown
case "txt", "text": return .text
case "pdf": return .pdf
case "png", "jpg", "jpeg", "webp", "gif": return .image
case "doc", "docx": return .word
case "xls", "xlsx": return .excel
case "ppt", "pptx": return .powerPoint
case "epub": return .epub
default: return .unknown
}
}
}
// MARK: - Knowledge Detail Page
struct KnowledgeDetailPage: View { struct KnowledgeDetailPage: View {
let item: KnowledgeItem let item: KnowledgeItem
@StateObject private var vm: KnowledgeDetailViewModel
@State private var showQuickLook = false
@State private var showNoteSheet = false
init(item: KnowledgeItem) {
self.item = item
_vm = StateObject(wrappedValue: KnowledgeDetailViewModel(item: item))
}
var body: some View { var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) { Group {
HStack { Spacer() switch vm.loadState {
NavigationLink(value: Route.editKnowledge(item: item)) { case .loading:
Image("icon-pencil").font(.system(size: 16)).foregroundColor(Color.zxF05) VStack(spacing: 12) {
.frame(width: 36, height: 36).background(Color(hex:"#FFFFFF",opacity:0.05)) ProgressView().tint(Color.zxPrimary)
.clipShape(RoundedRectangle(cornerRadius: 10)) Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04)
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1)) }.frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.zxCanvas)
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)
// Fallback: show summary or raw text
if let summary = item.summary, !summary.isEmpty {
ScrollView { Text(summary).font(.system(size: 14)).foregroundColor(Color.zxF0).lineSpacing(6).padding(20) }
} }
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 8) Button("重试") { Task { await vm.load() } }
ScrollView { VStack(spacing: 16) { .font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxPrimary)
VStack(alignment: .leading, spacing: 8) { .padding(.horizontal, 24).padding(.vertical, 10)
HStack { .background(Color.zxPrimarySoft).clipShape(RoundedRectangle(cornerRadius: 10))
if let itemType = item.itemType { ZXChip(text: itemType, color: Color.zxPurple) } }.frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.zxCanvas)
if let sourceType = item.sourceType { ZXChip(text: sourceType, color: Color.zxAccent) } case .ready:
if let path = vm.localPath, let mt = vm.detectedType {
MaterialReaderView(materialId: item.id, filePath: path, materialType: mt, title: item.title, showQuickLook: $showQuickLook, showNoteSheet: $showNoteSheet)
} else if let content = item.content {
// Plain text content render directly
ZStack { Color.zxCanvas.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: 12) {
if let summary = item.summary, !summary.isEmpty {
Text(summary).font(.system(size: 14)).foregroundColor(Color.zxF0).lineSpacing(6)
.padding(16).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14))
}
Text(content).font(.system(size: 14)).foregroundColor(Color.zxF0).lineSpacing(6)
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
}.scrollIndicators(.hidden)
}
}
}
}
.navigationTitle(item.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar(.hidden, for: .tabBar)
.toolbarBackground(.hidden, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
NavigationLink(value: Route.aiChat(context: ChatEntryContext(scopeType: .knowledgeItem, scopeId: item.id, scopeName: item.title, parentKnowledgeBaseId: item.knowledgeBaseId, createdFrom: "knowledge_item_detail"))) {
Label("AI 对话", systemImage: "bubble.left.and.bubble.right")
} }
Text(item.title).font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxF0)
if let content = item.content { Text(content).font(.system(size: 14)).foregroundColor(Color.zxF007).lineSpacing(6) }
}.padding(20).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20)).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
HStack(spacing: 12) {
NavigationLink(value: Route.studyHome) { NavigationLink(value: Route.studyHome) {
Label("开始复习", systemImage: "arrow.triangle.2.circlepath").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 44).background(ZXGradient.brandPurple).clipShape(RoundedRectangle(cornerRadius: 14)) Label("学习", systemImage: "arrow.triangle.2.circlepath")
} }
NavigationLink(value: Route.aiChat) { NavigationLink(value: Route.editKnowledge(item: item)) {
Label("费曼解释", systemImage: "mic").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF05).frame(maxWidth: .infinity).frame(height: 44).background(Color.zxFill005).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) Label("编辑", systemImage: "pencil")
}
if let mt = vm.detectedType {
let pm = previewMode(for: mt)
if pm == .nativeReader {
Button { showNoteSheet = true } label: {
Label("笔记", systemImage: "square.and.pencil")
} }
} }
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden) } if pm == .platformPreview {
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar)} Button { showQuickLook = true } label: {
Label("预览", systemImage: "eye")
}
}
}
} label: {
Image(systemName: "ellipsis.circle")
.font(.system(size: 16))
.foregroundColor(Color.zxF05)
}
}
}
.task { await vm.load() }
}
} }
struct ZXChip: View { let text: String; let color: Color struct ZXChip: View { let text: String; let color: Color
var body: some View { Text(text).font(.system(size: 10, weight: .semibold)).foregroundColor(color).padding(.horizontal, 8).padding(.vertical, 2).background(color.opacity(0.12)).clipShape(Capsule()) } var body: some View { Text(text).font(.system(size: 10, weight: .semibold)).foregroundColor(color).padding(.horizontal, 8).padding(.vertical, 2).background(color.opacity(0.12)).clipShape(Capsule()) }
} }

View File

@ -0,0 +1,277 @@
import SwiftUI
import Combine
// MARK: - ViewModel
@MainActor
final class MaterialDetailViewModel: ObservableObject {
@Published var kb: KnowledgeBase?
@Published var itemCount: Int = 0
@Published var quizCount: Int = 0
@Published var isLoading = true
@Published var aiStatus: AIStatus = .processing
enum AIStatus: Equatable {
case processing // AI
case ready //
case failed(String)
}
let knowledgeBaseId: String
init(knowledgeBaseId: String) {
self.knowledgeBaseId = knowledgeBaseId
}
func load() async {
isLoading = true
do {
kb = try await KnowledgeBaseService.shared.detail(id: knowledgeBaseId)
} catch {}
do {
let items = try await KnowledgeItemService.shared.list(knowledgeBaseId: knowledgeBaseId)
itemCount = items.count
} catch {}
do {
let quizzes = try await QuizService.shared.list(knowledgeBaseId: knowledgeBaseId)
quizCount = quizzes.count
} catch {}
aiStatus = itemCount > 0 ? .ready : .processing
isLoading = false
}
}
// MARK: - Main View
struct MaterialDetailView: View {
@StateObject private var vm: MaterialDetailViewModel
let knowledgeBaseId: String
let fileName: String
let fileType: MaterialType
let fileSize: UInt64
let filePath: String
let uploadDate: String?
init(knowledgeBaseId: String, fileName: String, fileType: MaterialType,
fileSize: UInt64, filePath: String, uploadDate: String? = nil) {
self.knowledgeBaseId = knowledgeBaseId
self.fileName = fileName
self.fileType = fileType
self.fileSize = fileSize
self.filePath = filePath
self.uploadDate = uploadDate
_vm = StateObject(wrappedValue: MaterialDetailViewModel(knowledgeBaseId: knowledgeBaseId))
}
var body: some View {
ZStack {
Color.zxCanvas.ignoresSafeArea()
ScrollView {
VStack(spacing: 20) {
// MARK: -
fileSection
// MARK: -
learningSection
Spacer().frame(height: 100)
}
.padding(.horizontal, 20).padding(.top, 8)
}
.scrollIndicators(.hidden)
}
.navigationTitle("资料详情")
.navigationBarTitleDisplayMode(.inline)
.toolbar(.hidden, for: .tabBar)
.toolbarBackground(.hidden, for: .navigationBar)
.task { await vm.load() }
}
// MARK: - File section
var fileSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 4) {
Image(systemName: "doc.fill").font(.system(size: 11)).foregroundColor(Color.zxF04)
Text("原文件").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF04)
.textCase(.uppercase).tracking(0.5)
}
// Status banner
statusBanner
// File metadata card
VStack(spacing: 0) {
fileInfoRow(icon: "doc", label: "文件名", value: fileName)
Divider().background(Color.zxBorder008).padding(.leading, 48)
fileInfoRow(icon: "info.circle", label: "格式", value: fileType.displayName)
Divider().background(Color.zxBorder008).padding(.leading, 48)
fileInfoRow(icon: "arrow.down.to.line", label: "大小", value: formatFileSize(fileSize))
Divider().background(Color.zxBorder008).padding(.leading, 48)
if let date = uploadDate {
fileInfoRow(icon: "calendar", label: "上传时间", value: date)
}
}
.padding(16)
.background(Color.zxSurfaceElevated)
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxHairline, lineWidth: 1))
// Read original file button
NavigationLink(value: Route.materialReader(
materialId: knowledgeBaseId,
filePath: filePath,
materialType: fileType
)) {
HStack(spacing: 8) {
Image(systemName: fileType == .pdf || fileType == .word || fileType == .excel ? "eye" : "book")
Text("阅读原文件")
}
.font(.system(size: 15, weight: .bold))
.foregroundColor(.white)
.frame(maxWidth: .infinity).frame(height: 48)
.background(ZXGradient.brand)
.clipShape(RoundedRectangle(cornerRadius: ZXRadius.lg))
}
}
}
// MARK: - Status banner
@ViewBuilder
var statusBanner: some View {
switch vm.aiStatus {
case .processing:
HStack(spacing: 10) {
ProgressView().tint(Color.zxAmber)
VStack(alignment: .leading, spacing: 2) {
Text("知习正在整理知识点").font(.system(size: 13, weight: .semibold)).foregroundColor(Color.zxAmberDeep)
Text("完成后可开始学习和自测").font(.system(size: 12)).foregroundColor(Color.zxAmber)
}
Spacer()
}
.padding(12)
.background(Color.zxAmberSoft.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 10))
case .ready:
HStack(spacing: 10) {
Image(systemName: "checkmark.circle.fill").font(.system(size: 16)).foregroundColor(Color.zxGreen)
VStack(alignment: .leading, spacing: 2) {
Text("资料已上传,可以先阅读原文件").font(.system(size: 13, weight: .semibold)).foregroundColor(Color.zxGreen)
Text("知识点已整理完成").font(.system(size: 12)).foregroundColor(Color.zxF04)
}
Spacer()
}
.padding(12)
.background(Color.zxMintSoft.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 10))
case .failed(let msg):
HStack(spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 16)).foregroundColor(Color.zxCoral)
Text(msg).font(.system(size: 13)).foregroundColor(Color.zxCoral)
Spacer()
}
.padding(12)
.background(Color.zxCoralSoft.opacity(0.25))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
// MARK: - File info row
func fileInfoRow(icon: String, label: String, value: String) -> some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 14)).foregroundColor(Color.zxF03)
.frame(width: 24, height: 24)
Text(label).font(.system(size: 13)).foregroundColor(Color.zxF04)
.frame(width: 60, alignment: .leading)
Text(value).font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxF0)
.lineLimit(1)
Spacer()
}
.padding(.vertical, 8)
}
// MARK: - Learning section
var learningSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 4) {
Image(systemName: "brain.head.profile").font(.system(size: 11)).foregroundColor(Color.zxF04)
Text("学习内容").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF04)
.textCase(.uppercase).tracking(0.5)
}
if vm.isLoading {
HStack { ProgressView().tint(Color.zxPrimary); Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04) }
.frame(maxWidth: .infinity).padding(.vertical, 24)
} else {
// Stats row
HStack(spacing: 12) {
statBox(count: vm.itemCount, label: "知识点", icon: "lightbulb", color: Color.zxPurple)
statBox(count: vm.quizCount, label: "测验", icon: "list.bullet.clipboard", color: Color.zxAccent)
}
// Action buttons
HStack(spacing: 12) {
NavigationLink(value: Route.aiChat(context: ChatEntryContext(scopeType: .material, scopeId: nil, scopeName: fileName, parentKnowledgeBaseId: knowledgeBaseId, createdFrom: "material_detail"))) {
Label("问这份资料", systemImage: "bubble.left.and.bubble.right")
.font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0)
.frame(maxWidth: .infinity).frame(height: 44)
.background(Color.zxFill005)
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
}
NavigationLink(value: Route.learningSession(
taskTitle: vm.kb?.title ?? "学习资料",
taskType: "study",
taskColorHex: "#3D7FFB"
)) {
Text("开始学习")
.font(.system(size: 14, weight: .bold)).foregroundColor(.white)
.frame(maxWidth: .infinity).frame(height: 44)
.background(ZXGradient.brandPurple)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
}
if vm.aiStatus == .processing {
Text("知习正在整理知识点,完成后可开始学习和自测")
.font(.system(size: 12)).foregroundColor(Color.zxF03)
.frame(maxWidth: .infinity, alignment: .center).padding(.top, 4)
}
}
}
}
// MARK: - Stat box
func statBox(count: Int, label: String, icon: String, color: Color) -> some View {
VStack(spacing: 6) {
Image(systemName: icon).font(.system(size: 18)).foregroundColor(color)
Text("\(count)").font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxF0)
Text(label).font(.system(size: 12)).foregroundColor(Color.zxF04)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(Color.zxSurface).clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
}
}
// MARK: - File size formatter
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))
}

View File

@ -0,0 +1,554 @@
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
let 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?
private let title: String
// Event collector records reading events during the session
private let collector = ReadingEventCollector.shared
private let positionStore = ReadingPositionStore.shared
init(materialId: String, filePath: String, materialType: MaterialType, title: String = "", showQuickLook: Binding<Bool> = .constant(false), showNoteSheet: Binding<Bool> = .constant(false)) {
self.title = title
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) {
NavigationLink(value: Route.aiChat(context: ChatEntryContext(
scopeType: .material,
scopeId: vm.materialId,
scopeName: title,
parentKnowledgeBaseId: nil,
createdFrom: "material_reader"
))) {
Image(systemName: "bubble.left.and.bubble.right")
.font(.system(size: 16))
.foregroundColor(Color.zxF05)
}
}
}
.task { 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 {
// FIXME: collector calls Rust FFI with struct-passing (broken on ARM64 iOS)
// collector.open(materialId: vm.materialId)
hasRestoredPosition = false
}
.onDisappear {
// FIXME: collector calls Rust FFI with struct-passing (broken on ARM64 iOS)
// if let lastPos = collector.lastPosition {
// positionStore.save(materialId: vm.materialId, position: lastPos)
// }
// _ = collector.close(materialId: vm.materialId)
}
.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) {
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(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
)
})
}
.coordinateSpace(name: "scroll")
.onPreferenceChange(ScrollOffsetKey.self) { offset in
scrollProgress = min(1, max(0, -offset / max(contentHeightEstimate, 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)
}
}
/// Estimate total block area height for scroll progress calculation
private var contentHeightEstimate: CGFloat {
let count = max(vm.blocks.count, 1)
return CGFloat(count) * 80
}
/// Build a NoteAnchor from the current scroll position (for quick note).
private func buildAnchor() -> NoteAnchor? {
guard let pos = collector.lastPosition else { return nil }
return createNoteAnchor(materialId: vm.materialId, position: pos)
}
/// Restore saved reading position on re-entry.
private func restorePosition() {
guard let saved = positionStore.load(materialId: vm.materialId) else { return }
switch saved {
case .markdown(let blockId, _):
restoreBlockId = blockId
case .text(let lineNumber, _):
let idx = Int(lineNumber) - 1
if idx >= 0, idx < vm.blocks.count {
restoreBlockId = blockId(from: vm.blocks[idx])
}
case .pdf, .image, .epub, .unknown:
break // PDF/image restoration needs platform-specific handling
}
}
/// Report current scroll position to the event collector.
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 block = vm.blocks[idx]
let bId = blockId(from: block)
let sp = Float(scrollProgress)
let pos: ReadingPosition
switch vm.materialType {
case .markdown:
pos = .markdown(blockId: bId, scrollProgress: sp)
case .text:
pos = .text(lineNumber: UInt32(idx + 1), scrollProgress: sp)
default:
pos = .markdown(blockId: bId, scrollProgress: sp)
}
collector.updatePosition(materialId: vm.materialId, position: pos)
}
private func blockId(from block: DocumentBlock) -> String {
switch block {
case .heading(let id, _, _): return id
case .paragraph(let id, _): return id
case .list(let id, _, _): return id
case .codeBlock(let id, _, _): return id
case .quote(let id, _): return id
case .table(let id, _, _): return id
case .imageBlock(let id, _, _): return id
case .horizontalRule(let id): return id
}
}
// 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: - 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 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))
}