feat(ios): IOS-M0-02 Document Import 文档导入
- 新增 DocumentImportService: create/getStatus - 新增 CreateImportRequest/ImportStatusResponse 模型 - ImportPage 重写: - 文件导入 → fileImporter → 上传 COS → POST /imports - 相册导入 → PhotosPicker → 上传 COS → POST /imports - 链接导入 → alert 输入 URL → POST /imports - 自动选第一个知识库作为导入目标 - 导入状态提示 + 错误处理 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
444f1842d0
commit
60d89e2bba
@ -438,6 +438,24 @@ struct SendMessageResponse: Codable {
|
|||||||
let message: String?
|
let message: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Document Import
|
||||||
|
|
||||||
|
struct CreateImportRequest: Codable {
|
||||||
|
let knowledgeBaseId: String
|
||||||
|
let fileName: String?
|
||||||
|
let sourceType: String?
|
||||||
|
let rawText: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ImportStatusResponse: Codable {
|
||||||
|
let id: String
|
||||||
|
let status: String?
|
||||||
|
let fileName: String?
|
||||||
|
let knowledgeBaseId: String?
|
||||||
|
let errorMessage: String?
|
||||||
|
let itemCount: Int?
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - File Upload (COS presigned URL flow)
|
// MARK: - File Upload (COS presigned URL flow)
|
||||||
|
|
||||||
struct FileUploadUrlRequest: Codable {
|
struct FileUploadUrlRequest: Codable {
|
||||||
|
|||||||
@ -303,6 +303,23 @@ class RagChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Document Import
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class DocumentImportService {
|
||||||
|
static let shared = DocumentImportService()
|
||||||
|
private let client = APIClient.shared
|
||||||
|
|
||||||
|
func create(knowledgeBaseId: String, fileName: String? = nil, sourceType: String? = "file", rawText: String? = nil) async throws -> ImportStatusResponse {
|
||||||
|
let body = CreateImportRequest(knowledgeBaseId: knowledgeBaseId, fileName: fileName, sourceType: sourceType, rawText: rawText)
|
||||||
|
return try await client.request("/imports", method: "POST", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStatus(id: String) async throws -> ImportStatusResponse {
|
||||||
|
return try await client.request("/imports/\(id)/status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Notifications
|
// MARK: - Notifications
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@ -573,19 +573,114 @@ struct ZXChip: View { let text: String; let color: Color
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ImportPage: View {
|
struct ImportPage: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var showFilePicker = false
|
||||||
|
@State private var showPhotoPicker = false
|
||||||
|
@State private var showLinkSheet = false
|
||||||
|
@State private var linkURL = ""
|
||||||
|
@State private var isImporting = false
|
||||||
|
@State private var kbId: String?
|
||||||
|
@State private var statusMessage = ""; @State private var importError: String?
|
||||||
|
@State private var selectedPhotoItem: PhotosPickerItem?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
|
ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
|
||||||
ScrollView { VStack(spacing: 12) {
|
ScrollView { VStack(spacing: 12) {
|
||||||
ZXImportOption(icon: "camera.fill", title: "拍照导入", desc: "拍下书本或笔记,AI 自动识别")
|
if let error = importError {
|
||||||
ZXImportOption(icon: "doc.text.fill", title: "文件导入", desc: "支持 PDF、Word、Markdown")
|
HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill").foregroundColor(.red); Text(error).font(.system(size: 13)).foregroundColor(.red) }
|
||||||
ZXImportOption(icon: "link", title: "链接导入", desc: "粘贴网页链接,自动提取内容")
|
.padding(12).background(Color.red.opacity(0.1)).clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
ZXImportOption(icon: "photo.on.rectangle", title: "相册导入", desc: "从相册选择截图或图片")
|
}
|
||||||
|
if !statusMessage.isEmpty {
|
||||||
|
HStack(spacing: 8) { ProgressView(); Text(statusMessage).font(.system(size: 13)).foregroundColor(Color.zxF04) }
|
||||||
|
.padding(12)
|
||||||
|
}
|
||||||
|
Button { showFilePicker = true } label: {
|
||||||
|
ZXImportRow(icon: "doc.text.fill", title: "文件导入", desc: "支持 PDF、Word、Markdown、TXT")
|
||||||
|
}
|
||||||
|
Button { showPhotoPicker = true } label: {
|
||||||
|
ZXImportRow(icon: "photo.on.rectangle", title: "相册导入", desc: "从相册选择截图或图片,AI 自动识别文字")
|
||||||
|
}
|
||||||
|
Button { showLinkSheet = true } label: {
|
||||||
|
ZXImportRow(icon: "link", title: "链接导入", desc: "粘贴网页链接,自动提取内容")
|
||||||
|
}
|
||||||
}.padding(.horizontal, 20).padding(.top, 8) }.scrollIndicators(.hidden) }
|
}.padding(.horizontal, 20).padding(.top, 8) }.scrollIndicators(.hidden) }
|
||||||
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).animatedTabBarHide()}
|
|
||||||
}
|
}
|
||||||
struct ZXImportOption: View { let icon: String; let title: String; let desc: String
|
.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).animatedTabBarHide()
|
||||||
var body: some View { Button { } label: { HStack(spacing: 14) { Image(systemName: icon).font(.system(size: 22)).foregroundColor(Color.zxPurple).frame(width: 48, height: 48).background(Color.zxPurpleBG(0.1)).clipShape(RoundedRectangle(cornerRadius: 14)); VStack(alignment: .leading, spacing: 2) { Text(title).font(.system(size: 15, weight: .semibold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF04) }; Spacer(); Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(Color.zxF03) }.padding(16).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 16)).overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1)) }.foregroundColor(.primary) }
|
.disabled(isImporting)
|
||||||
|
.task { do { let kbs = try await KnowledgeBaseService.shared.list(page: 1, limit: 1); kbId = kbs.first?.id } catch {} }
|
||||||
|
.fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.pdf, .plainText], allowsMultipleSelection: true) { result in
|
||||||
|
if case .success(let urls) = result { Task { await handleFiles(urls) } }
|
||||||
}
|
}
|
||||||
|
.photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItem, matching: .images)
|
||||||
|
.onChange(of: selectedPhotoItem) { _, item in
|
||||||
|
guard let item else { return }
|
||||||
|
Task { await handlePhoto(item) }
|
||||||
|
}
|
||||||
|
.alert("链接导入", isPresented: $showLinkSheet) {
|
||||||
|
TextField("输入网页 URL", text: $linkURL)
|
||||||
|
Button("导入") { Task { await handleLink() } }
|
||||||
|
Button("取消", role: .cancel) {}
|
||||||
|
} message: { Text("输入要导入的网页链接地址") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureKB() async -> String? {
|
||||||
|
if let id = kbId { return id }
|
||||||
|
do { let kbs = try await KnowledgeBaseService.shared.list(page: 1, limit: 1); kbId = kbs.first?.id; return kbId } catch { return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleFiles(_ urls: [URL]) async {
|
||||||
|
guard let kb = await ensureKB() else { importError = "请先创建一个知识库"; return }
|
||||||
|
isImporting = true; importError = nil
|
||||||
|
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
|
||||||
|
statusMessage = "上传 \(name)..."
|
||||||
|
do {
|
||||||
|
let mime = url.pathExtension.lowercased() == "pdf" ? "application/pdf" : "text/plain"
|
||||||
|
let _ = try await FileUploadService.shared.uploadData(data, filename: name, mimeType: mime)
|
||||||
|
statusMessage = "开始导入 \(name)..."
|
||||||
|
let _ = try await DocumentImportService.shared.create(knowledgeBaseId: kb, fileName: name, sourceType: "file")
|
||||||
|
statusMessage = "\(name) 导入任务已创建"
|
||||||
|
} catch { importError = "\(name) 导入失败: \(error.localizedDescription)" }
|
||||||
|
}
|
||||||
|
isImporting = false; statusMessage = ""
|
||||||
|
if importError == nil { ZXToastManager.shared.success("导入完成"); dismiss() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handlePhoto(_ item: PhotosPickerItem) async {
|
||||||
|
guard let kb = await ensureKB() else { importError = "请先创建一个知识库"; return }
|
||||||
|
isImporting = true; importError = nil; selectedPhotoItem = nil
|
||||||
|
guard let data = try? await item.loadTransferable(type: Data.self),
|
||||||
|
let image = UIImage(data: data) else { isImporting = false; return }
|
||||||
|
let name = "photo_\(Int(Date().timeIntervalSince1970)).jpg"
|
||||||
|
statusMessage = "上传图片..."
|
||||||
|
do {
|
||||||
|
let _ = try await FileUploadService.shared.uploadImage(image, filename: name)
|
||||||
|
statusMessage = "开始识别..."
|
||||||
|
let _ = try await DocumentImportService.shared.create(knowledgeBaseId: kb, fileName: name, sourceType: "file")
|
||||||
|
ZXToastManager.shared.success("图片已提交导入")
|
||||||
|
dismiss()
|
||||||
|
} catch { importError = "导入失败: \(error.localizedDescription)" }
|
||||||
|
isImporting = false; statusMessage = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleLink() async {
|
||||||
|
guard !linkURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||||
|
guard let kb = await ensureKB() else { importError = "请先创建一个知识库"; return }
|
||||||
|
isImporting = true; importError = nil
|
||||||
|
do {
|
||||||
|
let _ = try await DocumentImportService.shared.create(knowledgeBaseId: kb, sourceType: "link", rawText: linkURL)
|
||||||
|
ZXToastManager.shared.success("链接已提交导入")
|
||||||
|
dismiss()
|
||||||
|
} catch { importError = "导入失败: \(error.localizedDescription)" }
|
||||||
|
isImporting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ZXImportRow: View { let icon: String; let title: String; let desc: String
|
||||||
|
var body: some View { HStack(spacing: 14) { Image(systemName: icon).font(.system(size: 22)).foregroundColor(Color.zxPurple).frame(width: 48, height: 48).background(Color.zxPurpleBG(0.1)).clipShape(RoundedRectangle(cornerRadius: 14)); VStack(alignment: .leading, spacing: 2) { Text(title).font(.system(size: 15, weight: .semibold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF04) }; Spacer(); Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(Color.zxF03) }.padding(16).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 16)).overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1)) }
|
||||||
|
|
||||||
struct EditKnowledgePage: View {
|
struct EditKnowledgePage: View {
|
||||||
let item: KnowledgeItem
|
let item: KnowledgeItem
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user