feat(ios): 创建知识库支持上传封面图

- CreateLibraryPage 新增封面图区域(3:2,600×400)
- 自动裁剪 + 缩放到规范尺寸
- 上传到 COS,coverKey 传给后端
- FileUploadService 新增 uploadImageWithKey 返回 fileId+objectKey

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-05-28 10:43:00 +08:00
parent c021370a82
commit cc40d8b364
4 changed files with 100 additions and 9 deletions

View File

@ -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)

View File

@ -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)
}

View File

@ -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

View File

@ -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 {