feat(ios): AddKnowledgePage 支持文件上传(md/txt/pdf/图片)
- 新增内容来源切换:手写输入 | 上传文件 - 文件模式:支持 .md/.txt/.pdf 文档选择 + 图片选择 - FileUploadService 新增 uploadData 通用上传方法 - 上传到 COS → 创建知识点(content=下载URL, itemType=格式) - 手写模式保持原有文本编辑器 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
0860d7b8c2
commit
fe7aae7c27
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user