diff --git a/AIStudyApp/AIStudyApp/Core/Services/FileUploadService.swift b/AIStudyApp/AIStudyApp/Core/Services/FileUploadService.swift index 45818f1..bc05c4c 100644 --- a/AIStudyApp/AIStudyApp/Core/Services/FileUploadService.swift +++ b/AIStudyApp/AIStudyApp/Core/Services/FileUploadService.swift @@ -8,32 +8,32 @@ class FileUploadService { /// 上传图片到 COS 并返回文件 ID func uploadImage(_ image: UIImage, filename: String = "avatar.jpg") async throws -> String { - // 压缩为 JPEG,最大 512KB guard let imageData = image.jpegData(compressionQuality: 0.7) else { throw FileUploadError.compressFailed } + return try await uploadData(imageData, filename: filename, mimeType: "image/jpeg") + } - // 请求预签名上传 URL + /// 上传原始数据到 COS 并返回文件 ID + func uploadData(_ data: Data, filename: String, mimeType: String) async throws -> String { let urlReq = FileUploadUrlRequest( filename: filename, - mimeType: "image/jpeg", - sizeBytes: imageData.count + mimeType: mimeType, + sizeBytes: data.count ) let urlResp: FileUploadUrlResponse = try await client.request( "/files/upload-url", method: "POST", body: urlReq ) - // 直接 PUT 到 COS var request = URLRequest(url: URL(string: urlResp.uploadUrl)!) request.httpMethod = "PUT" - request.setValue("image/jpeg", forHTTPHeaderField: "Content-Type") - request.httpBody = imageData + request.setValue(mimeType, forHTTPHeaderField: "Content-Type") + request.httpBody = data let (_, response) = try await URLSession.shared.data(for: request) guard let httpResp = response as? HTTPURLResponse, httpResp.statusCode == 200 else { throw FileUploadError.uploadFailed } - // 确认上传 let confirmResp: FileConfirmUploadResponse = try await client.request( "/files/complete", method: "POST", body: FileConfirmUploadRequest(objectKey: urlResp.objectKey, checksum: nil) diff --git a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift index f540001..cdaa69c 100644 --- a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift +++ b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift @@ -108,17 +108,246 @@ struct ZXCardRow: View { let icon: String; let title: String; let desc: String; struct AddKnowledgePage: View { let knowledgeBaseId: String - @State private var title = ""; @State private var content = "" + @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 isUploading = false + @State private var isSaving = false + @State private var showFilePicker = false + @State private var showPhotoPicker = false + @State private var selectedPhotoItem: PhotosPickerItem? + + 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); 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)) } - 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)) } + // 标题 + 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) + .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) + 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) + 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(systemName: "doc.badge.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(systemName: "photo.on.rectangle") + 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 !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) + } + .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) + } + + if isUploading { + HStack(spacing: 8) { + ProgressView() + Text("上传中...").font(.system(size: 13)).foregroundColor(Color.zxF04) + } + } + } + + // 保存按钮 Button { - Task { _ = try? await KnowledgeItemService.shared.create(knowledgeBaseId: knowledgeBaseId, title: title, content: content.isEmpty ? nil : content) } - } label: { Text("保存").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) } + Task { await save() } + } label: { + HStack(spacing: 8) { + if isSaving { ProgressView().tint(.white) } + Text("保存").font(.system(size: 14, weight: .bold)) + } + .foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52) + .background(canSave ? ZXGradient.ctaPurple : 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)} + } + .navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar) + .fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.plainText, .pdf, .image], allowsMultipleSelection: false) { result in + if case .success(let urls) = result, let url = urls.first { handleFile(url) } + } + .photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItem, matching: .images) + .onChange(of: selectedPhotoItem) { _, item in + guard let item else { return } + Task { await handlePhoto(item) } + } + } + + // MARK: - Helpers + + private var fileIcon: String { + let ext = selectedFileName.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" } + if ext.hasSuffix(".jpg") || ext.hasSuffix(".jpeg") || ext.hasSuffix(".png") || ext.hasSuffix(".heic") { return "photo" } + return "doc" + } + + private var formatFileSize: String { + let size = selectedFileData?.count ?? 0 + 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() + 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" + } } struct KnowledgeDetailPage: View {