From 60d89e2bba3d4dee186f6ae2f31e07876e981f18 Mon Sep 17 00:00:00 2001 From: wangdl Date: Thu, 28 May 2026 20:07:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(ios):=20IOS-M0-02=20Document=20Import=20?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- .../AIStudyApp/Core/Models/APIModels.swift | 18 +++ .../AIStudyApp/Core/Services/APIService.swift | 17 +++ .../Features/Library/LibrarySubpages.swift | 111 ++++++++++++++++-- 3 files changed, 138 insertions(+), 8 deletions(-) diff --git a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift index 6225b7c..6e22677 100644 --- a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift +++ b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift @@ -438,6 +438,24 @@ struct SendMessageResponse: Codable { 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) struct FileUploadUrlRequest: Codable { diff --git a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift index 71273ac..762acde 100644 --- a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift +++ b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift @@ -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 @MainActor diff --git a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift index e8cea9c..d3c39c4 100644 --- a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift +++ b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift @@ -573,20 +573,115 @@ struct ZXChip: View { let text: String; let color: Color } 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 { ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) { ScrollView { VStack(spacing: 12) { - ZXImportOption(icon: "camera.fill", title: "拍照导入", desc: "拍下书本或笔记,AI 自动识别") - ZXImportOption(icon: "doc.text.fill", title: "文件导入", desc: "支持 PDF、Word、Markdown") - ZXImportOption(icon: "link", title: "链接导入", desc: "粘贴网页链接,自动提取内容") - ZXImportOption(icon: "photo.on.rectangle", title: "相册导入", desc: "从相册选择截图或图片") + if let error = importError { + HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill").foregroundColor(.red); Text(error).font(.system(size: 13)).foregroundColor(.red) } + .padding(12).background(Color.red.opacity(0.1)).clipShape(RoundedRectangle(cornerRadius: 12)) + } + 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) } - }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).animatedTabBarHide()} -} -struct ZXImportOption: View { let icon: String; let title: String; let desc: String - 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) } + } + .navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).animatedTabBarHide() + .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 { let item: KnowledgeItem @State private var title: String; @State private var content: String