feat(ios): 添加知识点 - 多选文件 + 标题可选 + 每个文件独立知识点

- 文件选择器改为多选(allowsMultipleSelection: true)
- 图片选择器支持多选(maxSelectionCount: 10)
- 标题改为可选,留空自动用文件名
- 每个文件独立上传 → 独立知识点
- 多文件时按钮显示"批量添加 (N个)"
- 已选文件可逐个删除
- 部分失败时显示 X/N 成功计数

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-05-27 22:26:32 +08:00
parent 42b60a21ec
commit 2b464df7cf

View File

@ -113,15 +113,15 @@ struct AddKnowledgePage: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var title = "" @State private var title = ""
@State private var content = "" @State private var content = ""
@State private var sourceType: SourceType = .manual @State private var sourceType: SourceType = .file
@State private var selectedFileURL: URL? @State private var selectedFiles: [SelectedFile] = []
@State private var selectedFileName = ""
@State private var selectedFileData: Data?
@State private var isUploading = false @State private var isUploading = false
@State private var isSaving = false @State private var isSaving = false
@State private var showFilePicker = false @State private var showFilePicker = false
@State private var showPhotoPicker = false @State private var showPhotoPicker = false
@State private var selectedPhotoItem: PhotosPickerItem? @State private var selectedPhotoItems: [PhotosPickerItem] = []
struct SelectedFile: Identifiable { let id = UUID(); let name: String; let data: Data; let icon: String; let size: String }
enum SourceType: String, CaseIterable { case manual = "手写", file = "文件" } enum SourceType: String, CaseIterable { case manual = "手写", file = "文件" }
@ -130,8 +130,8 @@ struct AddKnowledgePage: View {
ScrollView { VStack(spacing: 16) { ScrollView { VStack(spacing: 16) {
// //
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("标题").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035) Text("标题(可选,留空使用文件名)").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035)
TextField("输入知识点标题", text: $title).font(.system(size: 15)).tint(Color.zxPurple) TextField("输入知识点标题,留空则用文件名", text: $title).font(.system(size: 15)).tint(Color.zxPurple)
.padding(.horizontal, 16).frame(height: 52) .padding(.horizontal, 16).frame(height: 52)
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)) .background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
@ -159,7 +159,6 @@ struct AddKnowledgePage: View {
case .file: case .file:
VStack(spacing: 10) { VStack(spacing: 10) {
//
HStack(spacing: 8) { HStack(spacing: 8) {
Button { showFilePicker = true } label: { Button { showFilePicker = true } label: {
HStack { HStack {
@ -185,28 +184,39 @@ struct AddKnowledgePage: View {
} }
} }
// //
if !selectedFileName.isEmpty { if !selectedFiles.isEmpty {
HStack(spacing: 8) { VStack(spacing: 6) {
Image(systemName: fileIcon).foregroundColor(Color.zxGreen) ForEach(selectedFiles) { f in
Text(selectedFileName).font(.system(size: 13)).foregroundColor(Color.zxF0).lineLimit(1) HStack(spacing: 8) {
Spacer() Image(systemName: f.icon).foregroundColor(Color.zxGreen)
Text(formatFileSize).font(.system(size: 11)).foregroundColor(Color.zxF04) Text(f.name).font(.system(size: 13)).foregroundColor(Color.zxF0).lineLimit(1)
Spacer()
Text(f.size).font(.system(size: 11)).foregroundColor(Color.zxF04)
Button {
selectedFiles.removeAll { $0.id == f.id }
} label: {
Image(systemName: "xmark.circle.fill").font(.system(size: 16)).foregroundColor(Color.zxF04)
}
}
.padding(.horizontal, 12).padding(.vertical, 8)
.background(Color.zxFill004)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
} }
.padding(12).background(Color.zxFill004)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.zxBorder008, lineWidth: 1))
} }
// HStack {
Text("支持 Markdown、TXT、PDF、图片JPEG/PNG") Image(systemName: "info.circle").font(.system(size: 11))
.font(.system(size: 11)).foregroundColor(Color.zxF04) Text("支持多选,每个文件生成一个知识点")
}
.font(.system(size: 11)).foregroundColor(Color.zxF04)
} }
if isUploading { if isUploading {
HStack(spacing: 8) { HStack(spacing: 8) {
ProgressView() ProgressView()
Text("上传中...").font(.system(size: 13)).foregroundColor(Color.zxF04) Text("上传中 \(currentUploadIndex)/\(selectedFiles.count)...").font(.system(size: 13)).foregroundColor(Color.zxF04)
} }
} }
} }
@ -217,7 +227,7 @@ struct AddKnowledgePage: View {
} label: { } label: {
HStack(spacing: 8) { HStack(spacing: 8) {
if isSaving { ProgressView().tint(.white) } if isSaving { ProgressView().tint(.white) }
Text("保存").font(.system(size: 14, weight: .bold)) Text(sourceType == .file && selectedFiles.count > 1 ? "批量添加 (\(selectedFiles.count) 个)" : "保存").font(.system(size: 14, weight: .bold))
} }
.foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52) .foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52)
.background(canSave ? AnyView(ZXGradient.ctaPurple) : AnyView(Color.zxHairlineStrong)) .background(canSave ? AnyView(ZXGradient.ctaPurple) : AnyView(Color.zxHairlineStrong))
@ -227,20 +237,138 @@ struct AddKnowledgePage: View {
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80) }.scrollIndicators(.hidden) } }.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80) }.scrollIndicators(.hidden) }
} }
.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).animatedTabBarHide() .navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).animatedTabBarHide()
.fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.plainText, .pdf, .image], allowsMultipleSelection: false) { result in .fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.plainText, .pdf, .image], allowsMultipleSelection: true) { result in
if case .success(let urls) = result, let url = urls.first { handleFile(url) } if case .success(let urls) = result { handleFiles(urls) }
} }
.photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItem, matching: .images) .photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItems, maxSelectionCount: 10, matching: .images)
.onChange(of: selectedPhotoItem) { _, item in .onChange(of: selectedPhotoItems) { _, items in
guard let item else { return } guard !items.isEmpty else { return }
Task { await handlePhoto(item) } Task { await handlePhotos(items) }
} }
} }
// MARK: - Helpers // MARK: - File handling
private var fileIcon: String { @State private var currentUploadIndex = 0
let ext = selectedFileName.lowercased()
private var canSave: Bool {
switch sourceType {
case .manual: return !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
case .file: return !selectedFiles.isEmpty && !isUploading
}
}
private func handleFiles(_ urls: [URL]) {
for url in urls {
guard url.startAccessingSecurityScopedResource() else { continue }
defer { url.stopAccessingSecurityScopedResource() }
guard let data = try? Data(contentsOf: url) else { continue }
let name = url.lastPathComponent
selectedFiles.append(SelectedFile(
name: name,
data: data,
icon: fileIcon(name: name),
size: formatSize(data.count)
))
}
}
private func handlePhotos(_ items: [PhotosPickerItem]) async {
for item in items {
guard let data = try? await item.loadTransferable(type: Data.self) else { continue }
let compressed: Data
if let image = UIImage(data: data), data.count > 2 * 1024 * 1024 {
compressed = image.jpegData(compressionQuality: 0.6) ?? data
} else {
compressed = data
}
let name = "image_\(Int(Date().timeIntervalSince1970))_\(selectedFiles.count).jpg"
selectedFiles.append(SelectedFile(
name: name,
data: compressed,
icon: "photo",
size: formatSize(compressed.count)
))
}
selectedPhotoItems = []
}
private func save() async {
isSaving = true
defer { isSaving = false }
switch sourceType {
case .manual:
do {
_ = try await KnowledgeItemService.shared.create(
knowledgeBaseId: knowledgeBaseId,
title: title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "手写笔记" : title,
content: content,
itemType: "manual"
)
ZXToastManager.shared.success("知识点已添加")
dismiss()
} catch {
ZXToastManager.shared.error("添加失败: \(error.localizedDescription)")
}
case .file:
isUploading = true
defer { isUploading = false }
var successCount = 0
let total = selectedFiles.count
for (i, file) in selectedFiles.enumerated() {
currentUploadIndex = i + 1
do {
let mime = mimeType(name: file.name)
let ext = file.name.lowercased()
let itemType = ext.hasSuffix(".md") || ext.hasSuffix(".markdown") ? "markdown"
: ext.hasSuffix(".txt") ? "text"
: ext.hasSuffix(".pdf") ? "pdf"
: ext.hasSuffix(".jpg") || ext.hasSuffix(".jpeg") || ext.hasSuffix(".png") || ext.hasSuffix(".heic") ? "image"
: "file"
let fileId: String
if let image = UIImage(data: file.data) {
fileId = try await FileUploadService.shared.uploadImage(image, filename: file.name)
} else {
fileId = try await FileUploadService.shared.uploadData(file.data, filename: file.name, mimeType: mime)
}
let downloadUrl = try await FileUploadService.shared.getDownloadUrl(fileId: fileId)
//
let itemTitle: String
if !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
itemTitle = total == 1 ? title : "\(title) (\(i+1))"
} else {
itemTitle = file.name
}
_ = try await KnowledgeItemService.shared.create(
knowledgeBaseId: knowledgeBaseId,
title: itemTitle,
content: downloadUrl,
itemType: itemType
)
successCount += 1
} catch {
ZXToastManager.shared.error("\(file.name)」上传失败")
}
}
if successCount == total {
ZXToastManager.shared.success("\(total) 个知识点已添加")
dismiss()
} else if successCount > 0 {
ZXToastManager.shared.success("\(successCount)/\(total) 个知识点已添加")
dismiss()
}
}
}
private func fileIcon(name: String) -> String {
let ext = name.lowercased()
if ext.hasSuffix(".md") || ext.hasSuffix(".markdown") { return "doc.richtext" } if ext.hasSuffix(".md") || ext.hasSuffix(".markdown") { return "doc.richtext" }
if ext.hasSuffix(".txt") { return "doc.plaintext" } if ext.hasSuffix(".txt") { return "doc.plaintext" }
if ext.hasSuffix(".pdf") { return "doc.fill" } if ext.hasSuffix(".pdf") { return "doc.fill" }
@ -248,100 +376,14 @@ struct AddKnowledgePage: View {
return "doc" return "doc"
} }
private var formatFileSize: String { private func formatSize(_ size: Int) -> String {
let size = selectedFileData?.count ?? 0
if size < 1024 { return "\(size) B" } if size < 1024 { return "\(size) B" }
if size < 1024 * 1024 { return "\(size / 1024) KB" } if size < 1024 * 1024 { return "\(size / 1024) KB" }
return String(format: "%.1f MB", Double(size) / 1024 / 1024) return String(format: "%.1f MB", Double(size) / 1024 / 1024)
} }
private var canSave: Bool { private func mimeType(name: String) -> String {
guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } let ext = name.lowercased()
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(".md") || ext.hasSuffix(".markdown") { return "text/markdown" }
if ext.hasSuffix(".txt") { return "text/plain" } if ext.hasSuffix(".txt") { return "text/plain" }
if ext.hasSuffix(".pdf") { return "application/pdf" } if ext.hasSuffix(".pdf") { return "application/pdf" }