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:
wangdl 2026-05-28 20:07:20 +08:00
parent 444f1842d0
commit 60d89e2bba
3 changed files with 138 additions and 8 deletions

View File

@ -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 {

View File

@ -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

View File

@ -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