diff --git a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift index 9b17be8..2977484 100644 --- a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift +++ b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift @@ -174,6 +174,12 @@ struct KnowledgeBase: Codable, Identifiable { struct CreateKnowledgeBaseRequest: Codable { let title: String let description: String? + let coverKey: String? +} + +struct KnowledgeBaseCoverUploadResult { + let fileId: String + let objectKey: String } // MARK: - Knowledge Items (matches Prisma KnowledgeItem model) diff --git a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift index 0a9d012..d3dd331 100644 --- a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift +++ b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift @@ -85,8 +85,8 @@ class KnowledgeBaseService { ]) } - func create(title: String, description: String?) async throws -> KnowledgeBase { - let body = CreateKnowledgeBaseRequest(title: title, description: description) + func create(title: String, description: String?, coverKey: String? = nil) async throws -> KnowledgeBase { + let body = CreateKnowledgeBaseRequest(title: title, description: description, coverKey: coverKey) return try await client.request("/knowledge-bases", method: "POST", body: body) } diff --git a/AIStudyApp/AIStudyApp/Core/Services/FileUploadService.swift b/AIStudyApp/AIStudyApp/Core/Services/FileUploadService.swift index bc05c4c..c010470 100644 --- a/AIStudyApp/AIStudyApp/Core/Services/FileUploadService.swift +++ b/AIStudyApp/AIStudyApp/Core/Services/FileUploadService.swift @@ -6,12 +6,44 @@ class FileUploadService { static let shared = FileUploadService() private let client = APIClient.shared - /// 上传图片到 COS 并返回文件 ID - func uploadImage(_ image: UIImage, filename: String = "avatar.jpg") async throws -> String { - guard let imageData = image.jpegData(compressionQuality: 0.7) else { + /// 上传图片到 COS 并返回文件 ID + objectKey + func uploadImageWithKey(_ image: UIImage, filename: String = "cover.jpg") async throws -> (fileId: String, objectKey: String) { + guard let imageData = image.jpegData(compressionQuality: 0.75) else { throw FileUploadError.compressFailed } - return try await uploadData(imageData, filename: filename, mimeType: "image/jpeg") + return try await uploadDataWithKey(imageData, filename: filename, mimeType: "image/jpeg") + } + + /// 上传图片到 COS 并返回文件 ID + func uploadImage(_ image: UIImage, filename: String = "avatar.jpg") async throws -> String { + let result = try await uploadImageWithKey(image, filename: filename) + return result.fileId + } + + /// 上传原始数据到 COS 并返回 fileId + objectKey + func uploadDataWithKey(_ data: Data, filename: String, mimeType: String) async throws -> (fileId: String, objectKey: String) { + let urlReq = FileUploadUrlRequest( + filename: filename, mimeType: mimeType, sizeBytes: data.count + ) + let urlResp: FileUploadUrlResponse = try await client.request( + "/files/upload-url", method: "POST", body: urlReq + ) + + var request = URLRequest(url: URL(string: urlResp.uploadUrl)!) + request.httpMethod = "PUT" + 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) + ) + + return (fileId: confirmResp.id, objectKey: urlResp.objectKey) } /// 上传原始数据到 COS 并返回文件 ID diff --git a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift index 73baafa..21431b1 100644 --- a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift +++ b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift @@ -4,17 +4,42 @@ 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 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? var body: some View { ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) { ScrollView { VStack(spacing: 20) { + // 封面 + VStack(alignment: .leading, spacing: 8) { + Text("封面图(可选,3:2)").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035) + Button { showCoverPicker = 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(height: 160) + if let img = coverImage { + Image(uiImage: img).resizable().scaledToFill().frame(height: 160).clipShape(RoundedRectangle(cornerRadius: 14)) + } else { + VStack(spacing: 8) { + Image(systemName: "photo.badge.plus").font(.system(size: 28)).foregroundColor(Color.zxF04) + Text("点击上传封面图").font(.system(size: 13)).foregroundColor(Color.zxF04) + } + } + if isUploadingCover { RoundedRectangle(cornerRadius: 14).fill(Color.black.opacity(0.4)).frame(height: 160); ProgressView().tint(.white) } + } + }.disabled(isUploadingCover) + } VStack(alignment: .leading, spacing: 8) { Text("知识库名称").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextField("例如:机器学习", text: $name).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); TextField("简单描述这个知识库的内容", text: $desc).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)) } Button { guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } isCreating = true Task { - let kb = try? await KnowledgeBaseService.shared.create(title: name, description: desc.isEmpty ? nil : desc) + let kb = try? await KnowledgeBaseService.shared.create( + title: name, description: desc.isEmpty ? nil : desc, coverKey: coverKey + ) await MainActor.run { isCreating = false if kb != nil { @@ -36,7 +61,35 @@ struct CreateLibraryPage: View { } .disabled(isCreating || name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) }.padding(.horizontal, 20).padding(.top, 20) }.scrollIndicators(.hidden) } - }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).animatedTabBarHide()} + }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).animatedTabBarHide() + .photosPicker(isPresented: $showCoverPicker, selection: $coverPhotoItem, matching: .images) + .onChange(of: coverPhotoItem) { _, item in + guard let item else { return } + Task { await uploadCover(item) } + }} + } + + 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: 600, th: 400) + 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 {