feat(ios): 添加知识点 - 多选文件 + 标题可选 + 每个文件独立知识点
- 文件选择器改为多选(allowsMultipleSelection: true) - 图片选择器支持多选(maxSelectionCount: 10) - 标题改为可选,留空自动用文件名 - 每个文件独立上传 → 独立知识点 - 多文件时按钮显示"批量添加 (N个)" - 已选文件可逐个删除 - 部分失败时显示 X/N 成功计数 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
42b60a21ec
commit
2b464df7cf
@ -113,15 +113,15 @@ struct AddKnowledgePage: View {
|
||||
@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 sourceType: SourceType = .file
|
||||
@State private var selectedFiles: [SelectedFile] = []
|
||||
@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?
|
||||
@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 = "文件" }
|
||||
|
||||
@ -130,8 +130,8 @@ struct AddKnowledgePage: View {
|
||||
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)
|
||||
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))
|
||||
@ -159,7 +159,6 @@ struct AddKnowledgePage: View {
|
||||
|
||||
case .file:
|
||||
VStack(spacing: 10) {
|
||||
// 文件选择按钮
|
||||
HStack(spacing: 8) {
|
||||
Button { showFilePicker = true } label: {
|
||||
HStack {
|
||||
@ -185,28 +184,39 @@ struct AddKnowledgePage: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 已选文件
|
||||
if !selectedFileName.isEmpty {
|
||||
// 已选文件列表
|
||||
if !selectedFiles.isEmpty {
|
||||
VStack(spacing: 6) {
|
||||
ForEach(selectedFiles) { f in
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: fileIcon).foregroundColor(Color.zxGreen)
|
||||
Text(selectedFileName).font(.system(size: 13)).foregroundColor(Color.zxF0).lineLimit(1)
|
||||
Image(systemName: f.icon).foregroundColor(Color.zxGreen)
|
||||
Text(f.name).font(.system(size: 13)).foregroundColor(Color.zxF0).lineLimit(1)
|
||||
Spacer()
|
||||
Text(formatFileSize).font(.system(size: 11)).foregroundColor(Color.zxF04)
|
||||
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(12).background(Color.zxFill004)
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.vertical, 8)
|
||||
.background(Color.zxFill004)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.zxBorder008, lineWidth: 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 支持格式
|
||||
Text("支持 Markdown、TXT、PDF、图片(JPEG/PNG)")
|
||||
HStack {
|
||||
Image(systemName: "info.circle").font(.system(size: 11))
|
||||
Text("支持多选,每个文件生成一个知识点")
|
||||
}
|
||||
.font(.system(size: 11)).foregroundColor(Color.zxF04)
|
||||
}
|
||||
|
||||
if isUploading {
|
||||
HStack(spacing: 8) {
|
||||
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: {
|
||||
HStack(spacing: 8) {
|
||||
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)
|
||||
.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) }
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).animatedTabBarHide()
|
||||
.fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.plainText, .pdf, .image], allowsMultipleSelection: false) { result in
|
||||
if case .success(let urls) = result, let url = urls.first { handleFile(url) }
|
||||
.fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.plainText, .pdf, .image], allowsMultipleSelection: true) { result in
|
||||
if case .success(let urls) = result { handleFiles(urls) }
|
||||
}
|
||||
.photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItem, matching: .images)
|
||||
.onChange(of: selectedPhotoItem) { _, item in
|
||||
guard let item else { return }
|
||||
Task { await handlePhoto(item) }
|
||||
.photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItems, maxSelectionCount: 10, matching: .images)
|
||||
.onChange(of: selectedPhotoItems) { _, items in
|
||||
guard !items.isEmpty else { return }
|
||||
Task { await handlePhotos(items) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
// MARK: - File handling
|
||||
|
||||
private var fileIcon: String {
|
||||
let ext = selectedFileName.lowercased()
|
||||
@State private var currentUploadIndex = 0
|
||||
|
||||
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(".txt") { return "doc.plaintext" }
|
||||
if ext.hasSuffix(".pdf") { return "doc.fill" }
|
||||
@ -248,100 +376,14 @@ struct AddKnowledgePage: View {
|
||||
return "doc"
|
||||
}
|
||||
|
||||
private var formatFileSize: String {
|
||||
let size = selectedFileData?.count ?? 0
|
||||
private func formatSize(_ size: Int) -> String {
|
||||
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()
|
||||
private func mimeType(name: String) -> String {
|
||||
let ext = name.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" }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user