From 2b464df7cfdb7765995b6be470c3ea2253039329 Mon Sep 17 00:00:00 2001 From: wangdl Date: Wed, 27 May 2026 22:26:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(ios):=20=E6=B7=BB=E5=8A=A0=E7=9F=A5?= =?UTF-8?q?=E8=AF=86=E7=82=B9=20-=20=E5=A4=9A=E9=80=89=E6=96=87=E4=BB=B6?= =?UTF-8?q?=20+=20=E6=A0=87=E9=A2=98=E5=8F=AF=E9=80=89=20+=20=E6=AF=8F?= =?UTF-8?q?=E4=B8=AA=E6=96=87=E4=BB=B6=E7=8B=AC=E7=AB=8B=E7=9F=A5=E8=AF=86?= =?UTF-8?q?=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 文件选择器改为多选(allowsMultipleSelection: true) - 图片选择器支持多选(maxSelectionCount: 10) - 标题改为可选,留空自动用文件名 - 每个文件独立上传 → 独立知识点 - 多文件时按钮显示"批量添加 (N个)" - 已选文件可逐个删除 - 部分失败时显示 X/N 成功计数 Co-Authored-By: Claude Opus 4.7 --- .../Features/Library/LibrarySubpages.swift | 284 ++++++++++-------- 1 file changed, 163 insertions(+), 121 deletions(-) diff --git a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift index ff45f15..fd612fe 100644 --- a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift +++ b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift @@ -113,15 +113,15 @@ struct AddKnowledgePage: View { @Environment(\.dismiss) private var dismiss @State private var title = "" @State private var content = "" - @State private var sourceType: SourceType = .manual - @State private var selectedFileURL: URL? - @State private var selectedFileName = "" - @State private var selectedFileData: Data? + @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 selectedPhotoItem: PhotosPickerItem? + @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 = "文件" } @@ -130,8 +130,8 @@ struct AddKnowledgePage: View { 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: 15)).tint(Color.zxPurple) + Text("标题(可选,留空使用文件名)").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035) + TextField("输入知识点标题,留空则用文件名", text: $title).font(.system(size: 15)).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)) @@ -159,7 +159,6 @@ struct AddKnowledgePage: View { case .file: VStack(spacing: 10) { - // 文件选择按钮 HStack(spacing: 8) { Button { showFilePicker = true } label: { HStack { @@ -185,28 +184,39 @@ struct AddKnowledgePage: View { } } - // 已选文件 - if !selectedFileName.isEmpty { - HStack(spacing: 8) { - Image(systemName: fileIcon).foregroundColor(Color.zxGreen) - Text(selectedFileName).font(.system(size: 13)).foregroundColor(Color.zxF0).lineLimit(1) - Spacer() - Text(formatFileSize).font(.system(size: 11)).foregroundColor(Color.zxF04) + // 已选文件列表 + 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: 13)).foregroundColor(Color.zxF0).lineLimit(1) + Spacer() + Text(f.size).font(.system(size: 11)).foregroundColor(Color.zxF04) + Button { + selectedFiles.removeAll { $0.id == f.id } + } label: { + Image(systemName: "xmark.circle.fill").font(.system(size: 16)).foregroundColor(Color.zxF04) + } + } + .padding(.horizontal, 12).padding(.vertical, 8) + .background(Color.zxFill004) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } } - .padding(12).background(Color.zxFill004) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.zxBorder008, lineWidth: 1)) } - // 支持格式 - Text("支持 Markdown、TXT、PDF、图片(JPEG/PNG)") - .font(.system(size: 11)).foregroundColor(Color.zxF04) + HStack { + Image(systemName: "info.circle").font(.system(size: 11)) + Text("支持多选,每个文件生成一个知识点") + } + .font(.system(size: 11)).foregroundColor(Color.zxF04) } if isUploading { HStack(spacing: 8) { ProgressView() - Text("上传中...").font(.system(size: 13)).foregroundColor(Color.zxF04) + Text("上传中 \(currentUploadIndex)/\(selectedFiles.count)...").font(.system(size: 13)).foregroundColor(Color.zxF04) } } } @@ -217,7 +227,7 @@ struct AddKnowledgePage: View { } label: { HStack(spacing: 8) { if isSaving { ProgressView().tint(.white) } - Text("保存").font(.system(size: 14, weight: .bold)) + 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)) @@ -227,20 +237,138 @@ struct AddKnowledgePage: View { }.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80) }.scrollIndicators(.hidden) } } .navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).animatedTabBarHide() - .fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.plainText, .pdf, .image], allowsMultipleSelection: false) { result in - if case .success(let urls) = result, let url = urls.first { handleFile(url) } + .fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.plainText, .pdf, .image], allowsMultipleSelection: true) { result in + if case .success(let urls) = result { handleFiles(urls) } } - .photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItem, matching: .images) - .onChange(of: selectedPhotoItem) { _, item in - guard let item else { return } - Task { await handlePhoto(item) } + .photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItems, maxSelectionCount: 10, matching: .images) + .onChange(of: selectedPhotoItems) { _, items in + guard !items.isEmpty else { return } + Task { await handlePhotos(items) } } } - // MARK: - Helpers + // MARK: - File handling - private var fileIcon: String { - let ext = selectedFileName.lowercased() + @State private var currentUploadIndex = 0 + + private var canSave: Bool { + switch sourceType { + case .manual: return !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.fill" } @@ -248,100 +376,14 @@ struct AddKnowledgePage: View { return "doc" } - private var formatFileSize: String { - let size = selectedFileData?.count ?? 0 + 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 var canSave: Bool { - guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } - switch sourceType { - case .manual: return !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - case .file: return selectedFileData != nil && !isUploading - } - } - - private func handleFile(_ url: URL) { - guard url.startAccessingSecurityScopedResource() else { return } - defer { url.stopAccessingSecurityScopedResource() } - guard let data = try? Data(contentsOf: url) else { return } - selectedFileURL = url - selectedFileName = url.lastPathComponent - selectedFileData = data - } - - private func handlePhoto(_ item: PhotosPickerItem) async { - guard let data = try? await item.loadTransferable(type: Data.self), - let image = UIImage(data: data) else { return } - // 压缩大图 - let compressed: Data - if data.count > 2 * 1024 * 1024 { - compressed = image.jpegData(compressionQuality: 0.6) ?? data - } else { - compressed = data - } - selectedFileData = compressed - selectedFileName = "image_\(Int(Date().timeIntervalSince1970)).jpg" - selectedFileURL = nil - selectedPhotoItem = nil - } - - private func save() async { - isSaving = true - defer { isSaving = false } - - do { - let finalContent: String? - let finalType: String? - - switch sourceType { - case .manual: - finalContent = content - finalType = "manual" - - case .file: - guard let data = selectedFileData else { return } - isUploading = true - defer { isUploading = false } - - let mime = mimeType - let fileId: String - if let image = UIImage(data: data) { - fileId = try await FileUploadService.shared.uploadImage(image, filename: selectedFileName) - } else { - fileId = try await FileUploadService.shared.uploadData(data, filename: selectedFileName, mimeType: mime) - } - let downloadUrl = try await FileUploadService.shared.getDownloadUrl(fileId: fileId) - finalContent = downloadUrl - finalType = fileType - } - - _ = try await KnowledgeItemService.shared.create( - knowledgeBaseId: knowledgeBaseId, - title: title, - content: finalContent, - itemType: finalType - ) - ZXToastManager.shared.success("知识点已添加") - dismiss() - } catch { - ZXToastManager.shared.error("添加失败: \(error.localizedDescription)") - } - } - - private var fileType: String { - let ext = selectedFileName.lowercased() - if ext.hasSuffix(".md") || ext.hasSuffix(".markdown") { return "markdown" } - if ext.hasSuffix(".txt") { return "text" } - if ext.hasSuffix(".pdf") { return "pdf" } - if ext.hasSuffix(".jpg") || ext.hasSuffix(".jpeg") || ext.hasSuffix(".png") || ext.hasSuffix(".heic") { return "image" } - return "file" - } - - private var mimeType: String { - let ext = selectedFileName.lowercased() + 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" }