diff --git a/AIStudyApp/AIStudyApp.xcodeproj/project.pbxproj b/AIStudyApp/AIStudyApp.xcodeproj/project.pbxproj index 9c2a886..3508483 100644 --- a/AIStudyApp/AIStudyApp.xcodeproj/project.pbxproj +++ b/AIStudyApp/AIStudyApp.xcodeproj/project.pbxproj @@ -92,6 +92,7 @@ en, Base, "zh-Hans", + ja, ); mainGroup = 05F6CD122FA886330043A7BC; minimizedProjectReferenceProxies = 1; diff --git a/AIStudyApp/AIStudyApp.xcodeproj/project.xcworkspace/xcuserdata/Admin1.xcuserdatad/UserInterfaceState.xcuserstate b/AIStudyApp/AIStudyApp.xcodeproj/project.xcworkspace/xcuserdata/Admin1.xcuserdatad/UserInterfaceState.xcuserstate index 87f5d6e..1b90506 100644 Binary files a/AIStudyApp/AIStudyApp.xcodeproj/project.xcworkspace/xcuserdata/Admin1.xcuserdatad/UserInterfaceState.xcuserstate and b/AIStudyApp/AIStudyApp.xcodeproj/project.xcworkspace/xcuserdata/Admin1.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift.bak b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift.bak new file mode 100644 index 0000000..7f4d8ce --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift.bak @@ -0,0 +1,1522 @@ +import SwiftUI +import Combine +import PhotosUI +import UniformTypeIdentifiers + +struct CreateLibraryPage: View { + @Environment(\.dismiss) private var dismiss + @State private var name = ""; @State private var desc = "" + @State private var isCreating = false; @State private var isUploadingCover = false + @State private var coverKey: String?; @State private var coverImage: UIImage? + @State private var showCoverPicker = false; @State private var coverPhotoItem: PhotosPickerItem? + @State private var showCoverSheet = false + var body: some View { + ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) { + ScrollView { VStack(spacing: 20) { + // 封面图 + VStack(alignment: .leading, spacing: 8) { + Text("封面图").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035) + Button { showCoverSheet = true } label: { + ZStack { + RoundedRectangle(cornerRadius: 14).fill(Color.zxFill004) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(style: StrokeStyle(lineWidth: 1.5, dash: [6, 4])).foregroundColor(Color.zxBorder01)) + .frame(width: 120, height: 120) + if let img = coverImage { + Image(uiImage: img).resizable().scaledToFill().frame(width: 120, height: 120).clipShape(RoundedRectangle(cornerRadius: 14)) + } else { + VStack(spacing: 6) { + Image(systemName: "camera").font(.system(size: 22)).foregroundColor(Color.zxF04) + Text("上传").font(.system(size: 12)).foregroundColor(Color.zxF04) + } + } + if isUploadingCover { RoundedRectangle(cornerRadius: 14).fill(Color.black.opacity(0.4)).frame(width: 120, height: 120); ProgressView().tint(.white) } + } + } + .disabled(isUploadingCover) + } + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 2) { Text("知识库名称").font(.system(size: 12, weight: .semibold)); Text("*").foregroundColor(.red) }.foregroundColor(Color.zxF035) + TextField("例如:机器学习", text: $name).font(.system(size: 16)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 52).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) + } + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 2) { Text("描述").font(.system(size: 12, weight: .semibold)); Text("*").foregroundColor(.red) }.foregroundColor(Color.zxF035) + TextEditor(text: $desc).frame(minHeight: 80).scrollContentBackground(.hidden).padding(8).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) + } + Button { + guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !desc.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + isCreating = true + Task { + let kb = try? await KnowledgeBaseService.shared.create( + title: name, description: desc.isEmpty ? nil : desc, coverKey: coverKey + ) + await MainActor.run { + isCreating = false + if kb != nil { + ZXToastManager.shared.success("知识库已创建") + dismiss() + } else { + ZXToastManager.shared.error("创建失败,请重试") + } + } + } + } label: { + HStack(spacing: 8) { + if isCreating { ProgressView().tint(.white) } + Text("创建").font(.system(size: 14, weight: .bold)) + } + .foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52) + .background(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? AnyView(Color.zxHairlineStrong) : AnyView(ZXGradient.ctaPurple)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + .disabled(isCreating || name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || desc.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + }.padding(.horizontal, 20).padding(.top, 20) }.scrollIndicators(.hidden) } + }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar) + .photosPicker(isPresented: $showCoverPicker, selection: $coverPhotoItem, matching: .images) + .onChange(of: coverPhotoItem) { _, item in + guard let item else { return } + Task { await uploadCover(item) } + } + .sheet(isPresented: $showCoverSheet) { + VStack(spacing: 0) { + RoundedRectangle(cornerRadius: 3).fill(Color.zxF03).frame(width: 36, height: 5).padding(.top, 12).padding(.bottom, 16) + Text("封面图").font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0).padding(.bottom, 20) + Button { + showCoverSheet = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { showCoverPicker = true } + } label: { + HStack(spacing: 12) { + Image("icon-camera").font(.system(size: 20)).foregroundColor(Color.zxPrimary).frame(width: 40, height: 40).background(Color.zxPrimarySoft).clipShape(RoundedRectangle(cornerRadius: 10)) + VStack(alignment: .leading, spacing: 2) { + Text("从相册选择").font(.system(size: 16, weight: .medium)).foregroundColor(Color.zxF0) + Text("选择一张图片作为封面").font(.system(size: 12)).foregroundColor(Color.zxF04) + } + Spacer() + Image("icon-chevron-right").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(Color.zxF03) + } + .padding(.horizontal, 16).padding(.vertical, 14) + .background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)) + } + .foregroundColor(.primary) + .padding(.horizontal, 20) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.zxBg0) + .presentationDetents([.height(220)]) + .presentationDragIndicator(.hidden) + }} + + private func uploadCover(_ item: PhotosPickerItem) async { + isUploadingCover = true + defer { isUploadingCover = false; coverPhotoItem = nil } + guard let data = try? await item.loadTransferable(type: Data.self), + let image = UIImage(data: data) else { return } + let resized = resizeCoverImage(image, tw: 512, th: 512) + do { + let r = try await FileUploadService.shared.uploadImageWithKey(resized, filename: "cover_\(Int(Date().timeIntervalSince1970)).jpg") + coverKey = r.objectKey; coverImage = resized + } catch { ZXToastManager.shared.error("封面上传失败") } + } + + private func resizeCoverImage(_ image: UIImage, tw: CGFloat, th: CGFloat) -> UIImage { + let tr = tw / th; let ir = image.size.width / image.size.height + let rect: CGRect + if ir > tr { let nw = image.size.height * tr; rect = CGRect(x: (image.size.width - nw)/2, y: 0, width: nw, height: image.size.height) } + else { let nh = image.size.width / tr; rect = CGRect(x: 0, y: (image.size.height - nh)/2, width: image.size.width, height: nh) } + guard let cg = image.cgImage?.cropping(to: rect) else { return image } + let r = UIGraphicsImageRenderer(size: CGSize(width: tw, height: th)) + return r.image { _ in UIImage(cgImage: cg).draw(in: CGRect(origin: .zero, size: CGSize(width: tw, height: th))) } + } +} + +struct LibraryDetailPage: View { + let knowledgeBaseId: String + @Environment(\.dismiss) private var dismiss + @StateObject private var viewModel = LibraryDetailViewModel() + @State private var showDeleteConfirm = false + @State private var isDeleting = false + @State private var isSelectMode = false + @State private var selectedIds: Set = [] + @State private var showBatchDeleteConfirm = false + @State private var detailTab = 0 + @State private var sortOption = 0 + @State private var showFolderManager = false + @State private var sources: [KnowledgeSource] = [] + @State private var sourceReadingStatus: [String: String] = [:] + @State private var isLoadingSources = false + + private var allSelected: Bool { + !viewModel.items.isEmpty && selectedIds.count == viewModel.items.count + } + + var body: some View { + ZStack(alignment: .top) { + Color.zxBg0.ignoresSafeArea() + + // Top gradient wash — blue/purple fading to transparent + LinearGradient( + colors: [ + Color.zxPurple.opacity(0.08), + Color.zxPurple.opacity(0.04), + Color.clear, + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 220) + .ignoresSafeArea(edges: .top) + + VStack(spacing: 0) { + // KB info header — fixed, non-scrolling + if let kb = viewModel.knowledgeBase { + kbInfoHeader(kb) + .padding(.horizontal, 20).padding(.top, 8) + } + + + ScrollView { + VStack(spacing: 0) { + if detailTab == 0 { + if viewModel.isLoading && viewModel.items.isEmpty { + VStack(spacing: 12) { + ProgressView() + Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04) + } + .frame(maxWidth: .infinity).padding(.top, 80) + } + ForEach(viewModel.items) { item in + let icon = fileTypeIcon(for: item) + let type = fileTypeText(for: item) + let date = formatShortDate(item.updatedAt) + let progress = progressFor(item) + if isSelectMode { + Button { + if selectedIds.contains(item.id) { selectedIds.remove(item.id) } + else { selectedIds.insert(item.id) } + } label: { + HStack(spacing: 10) { + Image(systemName: selectedIds.contains(item.id) ? "checkmark.circle" : "circle") + .font(.system(size: 20)) + .foregroundColor(selectedIds.contains(item.id) ? Color.zxPrimary : Color.zxF03) + itemRow(icon: icon, title: item.title, type: type, date: date, progress: progress) + } + } + .foregroundColor(.primary) + } else { + NavigationLink(value: Route.knowledgeDetail(item: item)) { + itemRow(icon: icon, title: item.title, type: type, date: date, progress: progress) + } + .contextMenu { + Button { + isSelectMode = true + selectedIds.insert(item.id) + } label: { + Label("多选", systemImage: "checkmark.circle") + } + Button { + // TODO: rename + } label: { + Label("重命名", image: "icon-pencil") + } + Button { + // TODO: move to folder + } label: { + Label("移动到", image: "icon-folder") + } + Divider() + Button(role: .destructive) { + Task { + await viewModel.batchDeleteItems(ids: [item.id]) + await viewModel.refresh(knowledgeBaseId: knowledgeBaseId) + } + } label: { + Label("删除", image: "icon-trash") + } + } + } + } + if viewModel.items.isEmpty && !viewModel.isLoading { + Text("暂无知识点").font(.system(size: 14)).foregroundColor(Color.zxF03).padding(.top, 40) + } + if viewModel.hasMore { + ZXLoadMoreFooter { await viewModel.loadMore(knowledgeBaseId: knowledgeBaseId) } + } + } else { + if isLoadingSources { + VStack(spacing: 12) { + ProgressView().tint(Color.zxPurple) + Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04) + }.padding(.top, 80) + } else if sources.isEmpty { + Text("暂无资料来源").font(.system(size: 14)).foregroundColor(Color.zxF03).padding(.top, 40) + } else { + ForEach(sources) { src in + NavigationLink(value: Route.materialDetail( + knowledgeBaseId: knowledgeBaseId, + fileName: src.originalFilename ?? src.title ?? "未命名", + fileType: materialTypeFromMime(src.mimeType), + fileSize: 0, + filePath: "", + uploadDate: src.createdAt + )) { + HStack(spacing: 12) { + Image(systemName: src.type == "file" ? "doc" : "link") + .font(.system(size: 18)).foregroundColor(Color.zxPurple) + .frame(width: 36, height: 36) + VStack(alignment: .leading, spacing: 2) { + Text(src.originalFilename ?? src.title ?? "未命名") + .font(.system(size: 15, weight: .medium)).foregroundColor(Color.zxF0).lineLimit(1) + if let len = src.textLength, len > 0 { + Text("\(len) 字") + .font(.system(size: 13)).foregroundColor(Color.zxF04) + } + // Reading status badge + if let status = sourceReadingStatus[src.id], status != "not_started" { + Text(status == "read" ? "已读" : "阅读中") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(status == "read" ? Color.green : Color.zxPurple) + .padding(.horizontal, 6).padding(.vertical, 2) + .background((status == "read" ? Color.green : Color.zxPurple).opacity(0.1)) + .clipShape(Capsule()) + } + } + Spacer() + Button { + Task { await deleteSource(src) } + } label: { + Image("icon-trash").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(Color.zxF03) + } + } + .padding(.vertical, 14) + .overlay(alignment: .bottom) { + Color.zxHairline.frame(height: 0.5) + } + } + } + } + } + } + .padding(.horizontal, 20).padding(.bottom, 80) + } + .scrollIndicators(.hidden) + .refreshable { await viewModel.refresh(knowledgeBaseId: knowledgeBaseId) } + } + } + .navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar) + .onChange(of: detailTab) { _, newTab in + if newTab == 1 && sources.isEmpty { Task { await loadSources() } } + } + .toolbar { + if isSelectMode { + ToolbarItem(placement: .topBarLeading) { + Button("取消") { isSelectMode = false; selectedIds.removeAll() } + } + ToolbarItem(placement: .topBarTrailing) { + Button { selectedIds = allSelected ? [] : Set(viewModel.items.map(\.id)) } label: { + Text(allSelected ? "取消全选" : "全选").font(.system(size: 14)) + } + } + ToolbarItem(placement: .topBarTrailing) { + Button { + showBatchDeleteConfirm = true + } label: { + Image("icon-trash").resizable().scaledToFit().frame(width: 18, height: 18).foregroundColor(selectedIds.isEmpty ? Color.zxF03 : Color.zxCoral) + } + .disabled(selectedIds.isEmpty) + } + } else { + ToolbarItem(placement: .topBarTrailing) { + HStack(spacing: 4) { + Menu { + Button { sortOption = 0 } label: { + Label("默认排序", systemImage: sortOption == 0 ? "checkmark" : "") + } + Button { sortOption = 1 } label: { + Label("文件大小", systemImage: sortOption == 1 ? "checkmark" : "") + } + Button { sortOption = 2 } label: { + Label("创建日期", systemImage: sortOption == 2 ? "checkmark" : "") + } + Button { sortOption = 3 } label: { + Label("更新日期", systemImage: sortOption == 3 ? "checkmark" : "") + } + } label: { + Image(systemName: "arrow.up.arrow.down") + .font(.system(size: 16)) + .foregroundColor(Color.zxF05) + } + + Menu { + NavigationLink(value: Route.libraryImport) { + Label("上传资料", image: "icon-plus") + } + Button { + showFolderManager = true + } label: { + Label("分类管理", image: "icon-folder") + } + NavigationLink(value: Route.quizList(knowledgeBaseId: knowledgeBaseId)) { + Label("答题测验", image: "icon-pencil") + } + Button { + isSelectMode = true + } label: { + Label("批量选择", systemImage: "checkmark.circle") + } + Divider() + Button { + // TODO: knowledge base management page + } label: { + Label("知识库管理", image: "icon-settings") + } + } label: { + Image(systemName: "ellipsis.circle") + .font(.system(size: 20)) + .foregroundColor(Color.zxF05) + } + } // HStack + } + } + } + .alert("删除知识库", isPresented: $showDeleteConfirm) { + Button("取消", role: .cancel) {} + Button("删除", role: .destructive) { + isDeleting = true + Task { + await viewModel.deleteKnowledgeBase(id: knowledgeBaseId) + await MainActor.run { isDeleting = false; dismiss() } + } + } + } message: { + Text("删除后将无法恢复,包括其中的所有知识点。确定要删除吗?") + } + .alert("批量删除", isPresented: $showBatchDeleteConfirm) { + Button("取消", role: .cancel) {} + Button("删除 \(selectedIds.count) 个", role: .destructive) { + Task { + await viewModel.batchDeleteItems(ids: Array(selectedIds)) + isSelectMode = false + selectedIds.removeAll() + } + } + } message: { + Text("确定要删除选中的 \(selectedIds.count) 个知识点吗?此操作不可恢复。") + } + .task { await viewModel.loadItems(knowledgeBaseId: knowledgeBaseId) } + .sheet(isPresented: $showFolderManager) { + FolderManageView(knowledgeBaseId: knowledgeBaseId) + } + .onChange(of: sortOption) { _ in + let (sb, od) = sortParams(sortOption) + Task { await viewModel.loadItems(knowledgeBaseId: knowledgeBaseId, sortBy: sb, order: od) } + } + .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 { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 16) { + // Cover image + ZStack { + RoundedRectangle(cornerRadius: 16) + .fill(Color.zxPurpleBG(0.12)) + .frame(width: 80, height: 80) + if let coverUrl = kb.coverUrl, let url = resolvedCoverURL(coverUrl) { + AsyncImage(url: url) { phase in + switch phase { + case .success(let img): + img.resizable().scaledToFill() + .frame(width: 80, height: 80) + .clipShape(RoundedRectangle(cornerRadius: 16)) + default: + Image("icon-books") + .font(.system(size: 34)) + .foregroundColor(Color.zxPurple.opacity(0.4)) + } + } + } else { + Image("icon-books") + .font(.system(size: 34)) + .foregroundColor(Color.zxPurple.opacity(0.4)) + } + } + + VStack(alignment: .leading, spacing: 4) { + Text(kb.title) + .font(.system(size: 20, weight: .bold)) + .foregroundColor(Color.zxF0) + if let desc = kb.description, !desc.isEmpty { + Text(desc) + .font(.system(size: 14)) + .foregroundColor(Color.zxF04) + .lineLimit(2) + } else { + Text("暂无简介") + .font(.system(size: 14)) + .foregroundColor(Color.zxF04) + } + } + } + + // Info rows + VStack(alignment: .leading, spacing: 2) { + HStack { + infoLabel("来源", kb.ownerType == "official" ? "官方" : "个人") + .frame(maxWidth: .infinity, alignment: .leading) + if let created = kb.createdAt { + infoLabel("创建", String(created.prefix(10))) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + HStack { + if let count = kb.itemCount { + infoLabel("文件", "\(count) 个") + .frame(maxWidth: .infinity, alignment: .leading) + } + if let updated = kb.updatedAt { + infoLabel("更新", String(updated.prefix(10))) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .font(.system(size: 13)) + } + } + + private func infoLabel(_ label: String, _ value: String) -> some View { + HStack(spacing: 4) { + Text("\(label):") + .foregroundColor(Color.zxF03) + Text(value) + .foregroundColor(Color.zxF0) + } + } + + private func resolvedCoverURL(_ urlString: String) -> URL? { + if urlString.hasPrefix("http") { + return URL(string: urlString) + } + return URL(string: "https://longde.cloud\(urlString)") + } + + private func sortParams(_ option: Int) -> (String?, String?) { + switch option { + case 1: return ("fileSize", "desc") + case 2: return ("createdAt", "desc") + case 3: return ("updatedAt", "desc") + default: return (nil, nil) + } + } + + private func fileTypeText(for item: KnowledgeItem) -> String { + if let t = item.sourceType?.trimmingCharacters(in: .whitespaces), !t.isEmpty { + return t.uppercased() + } + let title = item.title.lowercased() + if title.hasSuffix(".pdf") { return "PDF" } + if title.hasSuffix(".md") { return "MD" } + if title.hasSuffix(".html") { return "HTML" } + if title.hasSuffix(".txt") { return "TXT" } + if title.hasSuffix(".png") || title.hasSuffix(".jpg") || title.hasSuffix(".jpeg") { return "图片" } + return "文件" + } + + private func fileTypeIcon(for item: KnowledgeItem) -> String { + let t = item.sourceType?.lowercased() ?? "" + let title = item.title.lowercased() + if t.contains("pdf") || title.hasSuffix(".pdf") { return "doc.richtext" } + if t.contains("markdown") || title.hasSuffix(".md") { return "doc.plaintext" } + if t.contains("html") || t.contains("code") || title.hasSuffix(".html") { return "doc.text" } + if t.contains("image") || title.hasSuffix(".png") || title.hasSuffix(".jpg") { return "photo" } + return "doc" + } + + private func itemRow(icon: String, title: String, type: String, date: String, progress: CGFloat) -> some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 18)) + .foregroundColor(Color.zxPurple) + .frame(width: 36, height: 36) + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.zxF0) + .lineLimit(1) + HStack(spacing: 16) { + HStack(spacing: 3) { + Text("类型:") + .foregroundColor(Color.zxF03) + Text(type) + .foregroundColor(Color.zxF04) + } + if !date.isEmpty { + HStack(spacing: 3) { + Text("创建:") + .foregroundColor(Color.zxF03) + Text(date) + .foregroundColor(Color.zxF04) + } + } + HStack(spacing: 3) { + Text("学习:") + .foregroundColor(Color.zxF03) + Text(formatDuration(progress)) + .foregroundColor(Color.zxF04) + } + } + .font(.system(size: 12)) + } + Spacer() + Image("icon-chevron-right") + .resizable().scaledToFit().frame(width: 14, height: 14) + .foregroundColor(Color.zxF03) + } + .padding(.vertical, 14) + .overlay(alignment: .bottom) { + Color.zxHairline.frame(height: 0.5) + } + } + + private func formatShortDate(_ iso: String?) -> String { + guard let iso else { return "" } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let date = formatter.date(from: iso) ?? { + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: iso) + }() + guard let date else { return "" } + let cal = Calendar.current + if cal.isDate(date, equalTo: Date(), toGranularity: .year) { + let df = DateFormatter() + df.dateFormat = "MM/dd" + return df.string(from: date) + } + let df = DateFormatter() + df.dateFormat = "yyyy/MM/dd" + return df.string(from: date) + } + + private func formatDuration(_ progress: CGFloat) -> String { + let totalSeconds = Int(progress * 1800) // up to 30 min + let h = totalSeconds / 3600 + let m = (totalSeconds % 3600) / 60 + let s = totalSeconds % 60 + if h > 0 { + return String(format: "%d:%02d:%02d", h, m, s) + } + return String(format: "%02d:%02d", m, s) + } + + private func progressFor(_ item: KnowledgeItem) -> CGFloat { + if item.status == "completed" { return 1.0 } + if item.status == "active" { return 0.5 } + return 0 + } + + private func loadSources() async { + isLoadingSources = true + do { sources = try await KnowledgeSourceService.shared.list(kbId: knowledgeBaseId) } catch {} + // Fetch reading status for sources + for src in sources.prefix(20) { + if let progress = try? await ReadingAPIService.shared.getReadingProgress( + materialId: src.id, targetType: "knowledge_source" + ) { + sourceReadingStatus[src.id] = progress.isMarkedRead ? "read" : progress.status + } else if MarkedReadStore.shared.contains(src.id) { + sourceReadingStatus[src.id] = "read" + } + } + isLoadingSources = false + } + + private func deleteSource(_ src: KnowledgeSource) async { + do { + let _ = try await KnowledgeSourceService.shared.delete(kbId: knowledgeBaseId, id: src.id) + sources.removeAll { $0.id == src.id } + ZXToastManager.shared.success("已删除") + } catch { ZXToastManager.shared.error("删除失败") } + } +} +struct ZXCardRow: View { let icon: String; let title: String; let desc: String; let status: String; let c: Color + var body: some View { HStack(spacing: 12) { Image(systemName: icon).font(.system(size: 18)).foregroundColor(c).frame(width: 40, height: 40).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 10)); VStack(alignment: .leading, spacing: 2) { Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF03) }; Spacer(); Text(status).font(.system(size: 10, weight: .semibold)).foregroundColor(c).padding(.horizontal, 8).padding(.vertical, 2).background(c.opacity(0.12)).clipShape(Capsule()) } + .padding(14).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder006, lineWidth: 1)) } +} + +struct AddKnowledgePage: View { + let knowledgeBaseId: String + @Environment(\.dismiss) private var dismiss + @State private var title = "" + @State private var content = "" + @State private var sourceType: SourceType = .file + @State private var selectedFiles: [SelectedFile] = [] + @State private var isUploading = false + @State private var isSaving = false + @State private var showFilePicker = false + @State private var showPhotoPicker = false + @State private var selectedPhotoItems: [PhotosPickerItem] = [] + + struct SelectedFile: Identifiable { let id = UUID(); let name: String; let data: Data; let icon: String; let size: String } + + enum SourceType: String, CaseIterable { case manual = "手写", file = "文件" } + + var body: some View { + ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) { + ScrollView { VStack(spacing: 16) { + // 内容来源选择 + VStack(alignment: .leading, spacing: 8) { + Text("内容来源").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035) + Picker("", selection: $sourceType) { + ForEach(SourceType.allCases, id: \.self) { t in Text(t.rawValue).tag(t) } + } + .pickerStyle(.segmented) + } + + // 内容区 + switch sourceType { + case .manual: + VStack(alignment: .leading, spacing: 8) { + Text("标题").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035) + TextField("输入知识点标题", text: $title).font(.system(size: 16)).tint(Color.zxPurple) + .padding(.horizontal, 16).frame(height: 52) + .background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) + } + VStack(alignment: .leading, spacing: 8) { + Text("内容").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035) + TextEditor(text: $content) + .frame(minHeight: 200).scrollContentBackground(.hidden).padding(12) + .background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) + } + + case .file: + VStack(spacing: 10) { + HStack(spacing: 8) { + Button { showFilePicker = true } label: { + HStack { + Image("icon-plus") + Text("选择文件") + } + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.zxPrimary) + .frame(maxWidth: .infinity).frame(height: 48) + .background(Color.zxPrimarySoft) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + Button { showPhotoPicker = true } label: { + HStack { + Image("icon-camera") + Text("图片") + } + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.zxAccent) + .frame(maxWidth: .infinity).frame(height: 48) + .background(Color.zxAccent.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } + + // 已选文件列表 + if !selectedFiles.isEmpty { + VStack(spacing: 6) { + ForEach(selectedFiles) { f in + HStack(spacing: 8) { + Image(systemName: f.icon).foregroundColor(Color.zxGreen) + Text(f.name).font(.system(size: 14)).foregroundColor(Color.zxF0).lineLimit(1) + Spacer() + Text(f.size).font(.system(size: 12)).foregroundColor(Color.zxF04) + Button { + selectedFiles.removeAll { $0.id == f.id } + } label: { + Image(systemName: "xmark.circle").font(.system(size: 16)).foregroundColor(Color.zxF04) + } + } + .padding(.horizontal, 12).padding(.vertical, 8) + .background(Color.zxFill004) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } + } + + HStack { + Image(systemName: "info.circle").font(.system(size: 12)) + Text("支持多选,每个文件生成一个知识点") + } + .font(.system(size: 12)).foregroundColor(Color.zxF04) + } + + if isUploading { + HStack(spacing: 8) { + ProgressView() + Text("上传中 \(currentUploadIndex)/\(selectedFiles.count)...").font(.system(size: 14)).foregroundColor(Color.zxF04) + } + } + } + + // 保存按钮 + Button { + Task { await save() } + } label: { + HStack(spacing: 8) { + if isSaving { ProgressView().tint(.white) } + Text(sourceType == .file && selectedFiles.count > 1 ? "批量添加 (\(selectedFiles.count) 个)" : "保存").font(.system(size: 14, weight: .bold)) + } + .foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52) + .background(canSave ? AnyView(ZXGradient.ctaPurple) : AnyView(Color.zxHairlineStrong)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + .disabled(!canSave || isSaving) + }.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80) }.scrollIndicators(.hidden) } + } + .navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar) + .fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.plainText, .pdf, .image], allowsMultipleSelection: true) { result in + if case .success(let urls) = result { handleFiles(urls) } + } + .photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItems, maxSelectionCount: 10, matching: .images) + .onChange(of: selectedPhotoItems) { _, items in + guard !items.isEmpty else { return } + Task { await handlePhotos(items) } + } + } + + // MARK: - File handling + + @State private var currentUploadIndex = 0 + + private var canSave: Bool { + switch sourceType { + case .manual: return !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + case .file: return !selectedFiles.isEmpty && !isUploading + } + } + + private func handleFiles(_ urls: [URL]) { + for url in urls { + guard url.startAccessingSecurityScopedResource() else { continue } + defer { url.stopAccessingSecurityScopedResource() } + guard let data = try? Data(contentsOf: url) else { continue } + let name = url.lastPathComponent + selectedFiles.append(SelectedFile( + name: name, + data: data, + icon: fileIcon(name: name), + size: formatSize(data.count) + )) + } + } + + private func handlePhotos(_ items: [PhotosPickerItem]) async { + for item in items { + guard let data = try? await item.loadTransferable(type: Data.self) else { continue } + let compressed: Data + if let image = UIImage(data: data), data.count > 2 * 1024 * 1024 { + compressed = image.jpegData(compressionQuality: 0.6) ?? data + } else { + compressed = data + } + let name = "image_\(Int(Date().timeIntervalSince1970))_\(selectedFiles.count).jpg" + selectedFiles.append(SelectedFile( + name: name, + data: compressed, + icon: "photo", + size: formatSize(compressed.count) + )) + } + selectedPhotoItems = [] + } + + private func save() async { + isSaving = true + defer { isSaving = false } + + switch sourceType { + case .manual: + do { + _ = try await KnowledgeItemService.shared.create( + knowledgeBaseId: knowledgeBaseId, + title: title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "手写笔记" : title, + content: content, + itemType: "manual" + ) + ZXToastManager.shared.success("知识点已添加") + dismiss() + } catch { + ZXToastManager.shared.error("添加失败: \(error.localizedDescription)") + } + + case .file: + isUploading = true + defer { isUploading = false } + var successCount = 0 + let total = selectedFiles.count + + for (i, file) in selectedFiles.enumerated() { + currentUploadIndex = i + 1 + do { + let mime = mimeType(name: file.name) + let ext = file.name.lowercased() + let itemType = ext.hasSuffix(".md") || ext.hasSuffix(".markdown") ? "markdown" + : ext.hasSuffix(".txt") ? "text" + : ext.hasSuffix(".pdf") ? "pdf" + : ext.hasSuffix(".jpg") || ext.hasSuffix(".jpeg") || ext.hasSuffix(".png") || ext.hasSuffix(".heic") ? "image" + : "file" + + let fileId: String + if let image = UIImage(data: file.data) { + fileId = try await FileUploadService.shared.uploadImage(image, filename: file.name) + } else { + fileId = try await FileUploadService.shared.uploadData(file.data, filename: file.name, mimeType: mime) + } + let downloadUrl = try await FileUploadService.shared.getDownloadUrl(fileId: fileId) + + // 标题:优先用户填写,否则用文件名 + let itemTitle: String + if !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + itemTitle = total == 1 ? title : "\(title) (\(i+1))" + } else { + itemTitle = file.name + } + + _ = try await KnowledgeItemService.shared.create( + knowledgeBaseId: knowledgeBaseId, + title: itemTitle, + content: downloadUrl, + itemType: itemType + ) + successCount += 1 + } catch { + ZXToastManager.shared.error("「\(file.name)」上传失败") + } + } + + if successCount == total { + ZXToastManager.shared.success("\(total) 个知识点已添加") + dismiss() + } else if successCount > 0 { + ZXToastManager.shared.success("\(successCount)/\(total) 个知识点已添加") + dismiss() + } + } + } + + private func fileIcon(name: String) -> String { + let ext = name.lowercased() + if ext.hasSuffix(".md") || ext.hasSuffix(".markdown") { return "doc.richtext" } + if ext.hasSuffix(".txt") { return "doc.plaintext" } + if ext.hasSuffix(".pdf") { return "doc" } + if ext.hasSuffix(".jpg") || ext.hasSuffix(".jpeg") || ext.hasSuffix(".png") || ext.hasSuffix(".heic") { return "photo" } + return "doc" + } + + private func formatSize(_ size: Int) -> String { + if size < 1024 { return "\(size) B" } + if size < 1024 * 1024 { return "\(size / 1024) KB" } + return String(format: "%.1f MB", Double(size) / 1024 / 1024) + } + + private func mimeType(name: String) -> String { + let ext = name.lowercased() + if ext.hasSuffix(".md") || ext.hasSuffix(".markdown") { return "text/markdown" } + if ext.hasSuffix(".txt") { return "text/plain" } + if ext.hasSuffix(".pdf") { return "application/pdf" } + if ext.hasSuffix(".jpg") || ext.hasSuffix(".jpeg") { return "image/jpeg" } + if ext.hasSuffix(".png") { return "image/png" } + if ext.hasSuffix(".heic") { return "image/heic" } + return "application/octet-stream" + } +} + +// MARK: - Knowledge Detail ViewModel + +@MainActor +final class KnowledgeDetailViewModel: ObservableObject { + @Published var localPath: String? + @Published var detectedType: MaterialType? + @Published var loadState: LoadState = .loading + @Published var isFavorited = false + + enum LoadState { case loading, ready, error(String) } + + let item: KnowledgeItem + + init(item: KnowledgeItem) { self.item = item } + + func toggleFavorite() async { + isFavorited.toggle() + // TODO: PATCH /knowledge-items/:id/status with favorited/unfavorited when backend supports it + } + + 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(""), 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("") { + 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 { + 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)) + } + + @State private var navigateToReader = false + @State private var readerPath: String = "" + @State private var readerType: MaterialType = .text + + var body: some View { + Group { + switch vm.loadState { + case .loading: + VStack(spacing: 12) { + ProgressView().tint(Color.zxPrimary) + Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04) + }.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) } + } + 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)) + }.frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.zxCanvas) + 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) { + // Source info + if let st = item.sourceType, !st.isEmpty { + HStack(spacing: 6) { + Image(systemName: "doc.text").font(.system(size: 11)) + Text(sourceLabel(for: st)).font(.system(size: 12)) + } + .foregroundColor(Color.zxF04) + .padding(.horizontal, 10).padding(.vertical, 4) + .background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 6)) + } + 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) + + // 阅读原文件 button + if let path = vm.localPath ?? (try? writeTempFile(content: content, itemId: item.id)), let mt = vm.detectedType ?? detectTypeFromContent(content, sourceType: item.sourceType) { + NavigationLink(value: Route.materialReader( + materialId: item.id, + filePath: path, + materialType: mt, + knowledgeBaseId: item.knowledgeBaseId, + title: item.title + )) { + HStack(spacing: 8) { + Image(systemName: "book") + Text("阅读原文件") + } + .font(.system(size: 15, weight: .bold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity).frame(height: 48) + .background(LinearGradient(colors: [Color.zxPurple, Color.zxAccent], startPoint: .leading, endPoint: .trailing)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + .padding(.top, 8) + } + }.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") + } + NavigationLink(value: Route.studyHome) { + Label("学习", systemImage: "arrow.triangle.2.circlepath") + } + NavigationLink(value: Route.editKnowledge(item: item)) { + 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") + } + } + if pm == .platformPreview { + Button { showQuickLook = true } label: { + Label("预览", systemImage: "eye") + } + } + } + Divider() + Button { + Task { + try? await ReviewService.shared.generateCards() + ZXToastManager.shared.success("已加入复习") + } + } label: { + Label("加入复习", systemImage: "arrow.triangle.2.circlepath") + } + Button { + // Toggle favorite — PATCH knowledge item status + Task { await vm.toggleFavorite() } + } label: { + Label(vm.isFavorited ? "取消收藏" : "收藏", systemImage: vm.isFavorited ? "star.fill" : "star") + } + } label: { + Image(systemName: "ellipsis.circle") + .font(.system(size: 16)) + .foregroundColor(Color.zxF05) + } + } + } + .task { await vm.load() } + } + + private func sourceLabel(for t: String) -> String { + switch t { + case "pdf": return "PDF 文档" + case "markdown": return "Markdown" + case "text": return "纯文本" + case "image": return "图片" + case "html": return "网页" + case "word": return "Word 文档" + case "excel": return "Excel 表格" + default: return t.uppercased() + } + } +} + +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()) } +} + +struct ImportPage: View { + @Environment(\.dismiss) private var dismiss + @State private var showFilePicker = false + @State private var showPhotoPicker = false + @State private var showLinkSheet = false + @State private var linkURL = "" + @State private var isImporting = false + @State private var kbId: String? + @State private var statusMessage = ""; @State private var importError: String? + @State private var selectedPhotoItem: PhotosPickerItem? + + var body: some View { + ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) { + ScrollView { VStack(spacing: 12) { + if let error = importError { + HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle").foregroundColor(.red); Text(error).font(.system(size: 14)).foregroundColor(.red) } + .padding(12) + + } + if !statusMessage.isEmpty { + HStack(spacing: 8) { ProgressView(); Text(statusMessage).font(.system(size: 14)).foregroundColor(Color.zxF04) } + .padding(12) + } + Button { showFilePicker = true } label: { + ZXImportRow(icon: "doc.text", title: "文件导入", desc: "支持 PDF、Word、Markdown、TXT") + } + Button { showPhotoPicker = true } label: { + ZXImportRow(icon: "photo.on.rectangle", title: "相册导入", desc: "从相册选择截图或图片,AI 自动识别文字") + } + Button { showLinkSheet = true } label: { + ZXImportRow(icon: "link", title: "链接导入", desc: "粘贴网页链接,自动提取内容") + } + }.padding(.horizontal, 20).padding(.top, 8) }.scrollIndicators(.hidden) } + } + .navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar) + .disabled(isImporting) + .task { do { let kbs = try await KnowledgeBaseService.shared.list(page: 1, limit: 1); kbId = kbs.first?.id } catch {} } + .fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.pdf, .plainText], allowsMultipleSelection: true) { result in + if case .success(let urls) = result { Task { await handleFiles(urls) } } + } + .photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItem, matching: .images) + .onChange(of: selectedPhotoItem) { _, item in + guard let item else { return } + Task { await handlePhoto(item) } + } + .alert("链接导入", isPresented: $showLinkSheet) { + TextField("输入网页 URL", text: $linkURL) + Button("导入") { Task { await handleLink() } } + Button("取消", role: .cancel) {} + } message: { Text("输入要导入的网页链接地址") } + } + + private func ensureKB() async -> String? { + if let id = kbId { return id } + do { let kbs = try await KnowledgeBaseService.shared.list(page: 1, limit: 1); kbId = kbs.first?.id; return kbId } catch { return nil } + } + + private func handleFiles(_ urls: [URL]) async { + guard let kb = await ensureKB() else { importError = "请先创建一个知识库"; return } + isImporting = true; importError = nil + for url in urls { + guard url.startAccessingSecurityScopedResource() else { continue } + defer { url.stopAccessingSecurityScopedResource() } + guard let data = try? Data(contentsOf: url) else { continue } + let name = url.lastPathComponent + statusMessage = "上传 \(name)..." + do { + let mime = url.pathExtension.lowercased() == "pdf" ? "application/pdf" : "text/plain" + let _ = try await FileUploadService.shared.uploadData(data, filename: name, mimeType: mime) + statusMessage = "开始导入 \(name)..." + let _ = try await DocumentImportService.shared.create(knowledgeBaseId: kb, fileName: name, sourceType: "file") + statusMessage = "\(name) 导入任务已创建" + } catch { importError = "\(name) 导入失败: \(error.localizedDescription)" } + } + isImporting = false; statusMessage = "" + if importError == nil { ZXToastManager.shared.success("导入完成"); dismiss() } + } + + private func handlePhoto(_ item: PhotosPickerItem) async { + guard let kb = await ensureKB() else { importError = "请先创建一个知识库"; return } + isImporting = true; importError = nil; selectedPhotoItem = nil + guard let data = try? await item.loadTransferable(type: Data.self), + let image = UIImage(data: data) else { isImporting = false; return } + let name = "photo_\(Int(Date().timeIntervalSince1970)).jpg" + statusMessage = "上传图片..." + do { + let _ = try await FileUploadService.shared.uploadImage(image, filename: name) + statusMessage = "开始识别..." + let _ = try await DocumentImportService.shared.create(knowledgeBaseId: kb, fileName: name, sourceType: "file") + ZXToastManager.shared.success("图片已提交导入") + dismiss() + } catch { importError = "导入失败: \(error.localizedDescription)" } + isImporting = false; statusMessage = "" + } + + private func handleLink() async { + guard !linkURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + guard let kb = await ensureKB() else { importError = "请先创建一个知识库"; return } + isImporting = true; importError = nil + do { + let _ = try await DocumentImportService.shared.create(knowledgeBaseId: kb, sourceType: "link", rawText: linkURL) + ZXToastManager.shared.success("链接已提交导入") + dismiss() + } catch { importError = "导入失败: \(error.localizedDescription)" } + isImporting = false + } +} + +struct ZXImportRow: View { let icon: String; let title: String; let desc: String + var body: some View { HStack(spacing: 14) { Image(systemName: icon).font(.system(size: 22)).foregroundColor(Color.zxPurple).frame(width: 48, height: 48) +; VStack(alignment: .leading, spacing: 2) { Text(title).font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF04) }; Spacer(); Image("icon-chevron-right").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(Color.zxF03) }.padding(16).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 16)).overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1)) } +} + +// MARK: - Import Review + +struct ImportReviewPage: View { + let sourceId: String + @Environment(\.dismiss) private var dismiss + @State private var candidates: [ImportCandidate] = [] + @State private var isLoading = true + @State private var isProcessing = false + @State private var errorMessage: String? + @State private var expandedId: String? + + var body: some View { + ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) { + if isLoading { + VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载候选...").font(.system(size: 14)).foregroundColor(Color.zxF04) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if candidates.isEmpty { + VStack(spacing: 12) { + Image(systemName: "checkmark.circle").font(.system(size: 36)).foregroundColor(Color.zxGreen) + Text("没有待审批的候选").font(.system(size: 14)).foregroundColor(Color.zxF04) + }.frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + VStack(spacing: 12) { + HStack { + Text("\(candidates.count) 个候选知识点").font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxF04) + Spacer() + Button { + Task { await batchAccept() } + } label: { + Text("全部接受").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxPrimary) + } + .disabled(isProcessing) + } + ForEach(candidates) { c in + VStack(spacing: 0) { + Button { + withAnimation { expandedId = expandedId == c.id ? nil : c.id } + } label: { + HStack(spacing: 12) { + if let conf = c.confidence { + Text("\(Int(conf * 100))%").font(.system(size: 10, weight: .bold)) + .foregroundColor(conf > 0.7 ? Color.zxGreen : Color.zxAmber) + .padding(.horizontal, 6).padding(.vertical, 2) + .background((conf > 0.7 ? Color.zxGreen : Color.zxAmber).opacity(0.12)) + .clipShape(Capsule()) + } + VStack(alignment: .leading, spacing: 4) { + Text(c.title ?? "无标题").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0).lineLimit(1) + if let content = c.content { + Text(content).font(.system(size: 12)).foregroundColor(Color.zxF04).lineLimit(2) + } + } + Spacer() + Image(systemName: expandedId == c.id ? "chevron.up" : "chevron.down").font(.system(size: 12)).foregroundColor(Color.zxF03) + } + .padding(12).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 14)) + } + .foregroundColor(.primary) + + if expandedId == c.id { + HStack(spacing: 8) { + Button { + Task { await rejectCandidate(c) } + } label: { + Label("拒绝", systemImage: "xmark").font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.zxCoral).frame(maxWidth: .infinity).frame(height: 40) + + + } + Button { + Task { await acceptCandidate(c) } + } label: { + Label("接受", systemImage: "checkmark").font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.zxGreen).frame(maxWidth: .infinity).frame(height: 40) + + + } + } + .padding(.horizontal, 12).padding(.top, 8) + } + } + } + } + .padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100) + } + .scrollIndicators(.hidden) + } + }} + .navigationTitle("候选审批").navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar) + .disabled(isProcessing) + .task { await load() } + } + + private func load() async { + isLoading = true; errorMessage = nil + do { candidates = try await ImportCandidateService.shared.listBySource(sourceId: sourceId) } catch { errorMessage = error.localizedDescription } + isLoading = false + } + + private func acceptCandidate(_ c: ImportCandidate) async { + isProcessing = true + do { + let _ = try await ImportCandidateService.shared.accept(id: c.id) + candidates.removeAll { $0.id == c.id } + ZXToastManager.shared.success("已接受") + } catch { ZXToastManager.shared.error("操作失败") } + isProcessing = false + } + + private func rejectCandidate(_ c: ImportCandidate) async { + isProcessing = true + do { + let _ = try await ImportCandidateService.shared.reject(id: c.id) + candidates.removeAll { $0.id == c.id } + ZXToastManager.shared.success("已拒绝") + } catch { ZXToastManager.shared.error("操作失败") } + isProcessing = false + } + + private func batchAccept() async { + isProcessing = true + do { + let _ = try await ImportCandidateService.shared.batchAccept(sourceId: sourceId) + candidates.removeAll() + ZXToastManager.shared.success("全部接受") + } catch { ZXToastManager.shared.error("操作失败") } + isProcessing = false + } +} + +struct EditKnowledgePage: View { + let item: KnowledgeItem + @State private var title: String; @State private var content: String + + init(item: KnowledgeItem) { + self.item = item + _title = State(initialValue: item.title) + _content = State(initialValue: item.content ?? "") + } + var body: some View { + ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) { + ScrollView { VStack(spacing: 16) { + VStack(alignment: .leading, spacing: 8) { Text("标题").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextField("", text: $title).font(.system(size: 16)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 52).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) } + VStack(alignment: .leading, spacing: 8) { Text("内容").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextEditor(text: $content).frame(minHeight: 200).scrollContentBackground(.hidden).padding(12).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) } + Button { + Task { _ = try? await KnowledgeItemService.shared.update(id: item.id, title: title, content: content, summary: nil) } + } label: { Text("保存修改").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) } + }.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80) }.scrollIndicators(.hidden) } + }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar) + } +} + +// MARK: - Helpers + +private func materialTypeFromMime(_ mime: String?) -> MaterialType { + guard let m = mime?.lowercased() else { return .markdown } + if m.contains("pdf") { return .pdf } + if m.contains("epub") { return .epub } + if m.contains("word") || m.contains("docx") || m.contains("doc") { return .word } + if m.contains("excel") || m.contains("xlsx") { return .excel } + if m.contains("powerpoint") || m.contains("pptx") { return .powerPoint } + if m.contains("image") { return .image } + if m.contains("text/plain") { return .text } + return .markdown +} + +private func writeTempFile(content: String, itemId: String) throws -> String { + let dir = FileManager.default.temporaryDirectory + let url = dir.appendingPathComponent("kd_text_\(itemId).md") + try content.write(to: url, atomically: true, encoding: .utf8) + return url.path +} + +private func detectTypeFromContent(_ content: String, sourceType: String?) -> MaterialType? { + if let st = sourceType { + switch st.lowercased() { + case "markdown", "md": return .markdown + case "txt", "text": return .text + case "pdf": return .pdf + case "epub": return .epub + case "docx", "doc": return .word + case "xlsx", "xls": return .excel + case "pptx", "ppt": return .powerPoint + default: break + } + } + // Detect from content + if content.hasPrefix("#") || content.contains("```") { return .markdown } + return .text +}