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
|
@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" }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user