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:
parent
c021370a82
commit
cc40d8b364
@ -174,6 +174,12 @@ struct KnowledgeBase: Codable, Identifiable {
|
|||||||
struct CreateKnowledgeBaseRequest: Codable {
|
struct CreateKnowledgeBaseRequest: Codable {
|
||||||
let title: String
|
let title: String
|
||||||
let description: String?
|
let description: String?
|
||||||
|
let coverKey: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct KnowledgeBaseCoverUploadResult {
|
||||||
|
let fileId: String
|
||||||
|
let objectKey: String
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Knowledge Items (matches Prisma KnowledgeItem model)
|
// MARK: - Knowledge Items (matches Prisma KnowledgeItem model)
|
||||||
|
|||||||
@ -85,8 +85,8 @@ class KnowledgeBaseService {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func create(title: String, description: String?) async throws -> KnowledgeBase {
|
func create(title: String, description: String?, coverKey: String? = nil) async throws -> KnowledgeBase {
|
||||||
let body = CreateKnowledgeBaseRequest(title: title, description: description)
|
let body = CreateKnowledgeBaseRequest(title: title, description: description, coverKey: coverKey)
|
||||||
return try await client.request("/knowledge-bases", method: "POST", body: body)
|
return try await client.request("/knowledge-bases", method: "POST", body: body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,12 +6,44 @@ class FileUploadService {
|
|||||||
static let shared = FileUploadService()
|
static let shared = FileUploadService()
|
||||||
private let client = APIClient.shared
|
private let client = APIClient.shared
|
||||||
|
|
||||||
/// 上传图片到 COS 并返回文件 ID
|
/// 上传图片到 COS 并返回文件 ID + objectKey
|
||||||
func uploadImage(_ image: UIImage, filename: String = "avatar.jpg") async throws -> String {
|
func uploadImageWithKey(_ image: UIImage, filename: String = "cover.jpg") async throws -> (fileId: String, objectKey: String) {
|
||||||
guard let imageData = image.jpegData(compressionQuality: 0.7) else {
|
guard let imageData = image.jpegData(compressionQuality: 0.75) else {
|
||||||
throw FileUploadError.compressFailed
|
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
|
/// 上传原始数据到 COS 并返回文件 ID
|
||||||
|
|||||||
@ -4,17 +4,42 @@ import UniformTypeIdentifiers
|
|||||||
|
|
||||||
struct CreateLibraryPage: View {
|
struct CreateLibraryPage: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@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 {
|
var body: some View {
|
||||||
ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
|
ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
|
||||||
ScrollView { VStack(spacing: 20) {
|
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: $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)) }
|
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 {
|
Button {
|
||||||
guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||||
isCreating = true
|
isCreating = true
|
||||||
Task {
|
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 {
|
await MainActor.run {
|
||||||
isCreating = false
|
isCreating = false
|
||||||
if kb != nil {
|
if kb != nil {
|
||||||
@ -36,7 +61,35 @@ struct CreateLibraryPage: View {
|
|||||||
}
|
}
|
||||||
.disabled(isCreating || name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
.disabled(isCreating || name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
}.padding(.horizontal, 20).padding(.top, 20) }.scrollIndicators(.hidden) }
|
}.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 {
|
struct LibraryDetailPage: View {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user