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:
wangdl 2026-05-27 22:05:33 +08:00
parent 0860d7b8c2
commit fe7aae7c27
2 changed files with 243 additions and 14 deletions

View File

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

View File

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