diff --git a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift index 8ad98b6..c76c688 100644 --- a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift +++ b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift @@ -472,6 +472,27 @@ struct BatchAcceptRequest: Codable { let sourceId: String } +// MARK: - Knowledge Source + +struct KnowledgeSource: Codable, Identifiable { + let id: String + let knowledgeBaseId: String? + let title: String? + let originalFilename: String? + let type: String? + let mimeType: String? + let parseStatus: String? + let indexStatus: String? + let textLength: Int? + let createdAt: String? +} + +struct AddSourceRequest: Codable { + let fileId: String? + let type: String? + let title: String? +} + // 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 de985b1..87d032e 100644 --- a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift +++ b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift @@ -356,6 +356,31 @@ class ImportCandidateService { } } +// MARK: - Knowledge Source + +@MainActor +class KnowledgeSourceService { + static let shared = KnowledgeSourceService() + private let client = APIClient.shared + + func list(kbId: String) async throws -> [KnowledgeSource] { + return try await client.request("/knowledge-bases/\(kbId)/sources") + } + + func detail(kbId: String, id: String) async throws -> KnowledgeSource { + return try await client.request("/knowledge-bases/\(kbId)/sources/\(id)") + } + + func add(kbId: String, fileId: String? = nil, type: String? = nil, title: String? = nil) async throws -> KnowledgeSource { + let body = AddSourceRequest(fileId: fileId, type: type, title: title) + return try await client.request("/knowledge-bases/\(kbId)/sources", method: "POST", body: body) + } + + func delete(kbId: String, id: String) async throws -> GenericSuccessResponse { + return try await client.request("/knowledge-bases/\(kbId)/sources/\(id)", method: "DELETE") + } +} + // MARK: - Notifications @MainActor diff --git a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift index 6d03471..9a5021d 100644 --- a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift +++ b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift @@ -140,6 +140,9 @@ struct LibraryDetailPage: View { @State private var isSelectMode = false @State private var selectedIds: Set = [] @State private var showBatchDeleteConfirm = false + @State private var detailTab = 0 + @State private var sources: [KnowledgeSource] = [] + @State private var isLoadingSources = false private var allSelected: Bool { !viewModel.items.isEmpty && selectedIds.count == viewModel.items.count @@ -147,7 +150,15 @@ struct LibraryDetailPage: View { var body: some View { ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) { + Picker("", selection: $detailTab) { + Text("知识点").tag(0) + Text("资料来源").tag(1) + } + .pickerStyle(.segmented) + .padding(.horizontal, 20).padding(.top, 8) + ScrollView { VStack(spacing: 12) { + if detailTab == 0 { if viewModel.isLoading && viewModel.items.isEmpty { VStack(spacing: 12) { ZXLoadingView(size: 36, lineWidth: 3); Text("加载中…").font(.system(size: 13)).foregroundColor(Color.zxF04) } .frame(maxWidth: .infinity).padding(.top, 80) @@ -180,10 +191,51 @@ struct LibraryDetailPage: View { } }.padding(.horizontal, 20).padding(.bottom, 80) } .scrollIndicators(.hidden) + .zxPullToRefresh { await viewModel.refresh(knowledgeBaseId: knowledgeBaseId) } + } else { + // 资料来源 Tab + if isLoadingSources { + VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载中…").font(.system(size: 13)).foregroundColor(Color.zxF04) }.padding(.top, 80) + } else if sources.isEmpty { + Text("暂无资料来源").font(.system(size: 13)).foregroundColor(Color.zxF03).padding(.top, 40) + } else { + ForEach(sources) { src in + VStack(spacing: 0) { + HStack(spacing: 12) { + Image(systemName: src.type == "file" ? "doc.fill" : "link") + .font(.system(size: 18)).foregroundColor(Color.zxPurple) + .frame(width: 40, height: 40).background(Color.zxPurpleBG(0.12)).clipShape(RoundedRectangle(cornerRadius: 10)) + VStack(alignment: .leading, spacing: 2) { + Text(src.title ?? src.originalFilename ?? "未命名").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0).lineLimit(1) + HStack(spacing: 6) { + Text(src.parseStatus ?? "pending").font(.system(size: 10, weight: .semibold)) + .foregroundColor(src.parseStatus == "completed" ? Color.zxGreen : Color.zxAmber) + .padding(.horizontal, 6).padding(.vertical, 1) + .background((src.parseStatus == "completed" ? Color.zxGreen : Color.zxAmber).opacity(0.12)).clipShape(Capsule()) + if let len = src.textLength, len > 0 { Text("\(len) 字").font(.system(size: 10)).foregroundColor(Color.zxF04) } + } + } + Spacer() + Button { + Task { await deleteSource(src) } + } label: { + Image(systemName: "trash").font(.system(size: 14)).foregroundColor(Color.zxF03) + } + } + .padding(12).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 14)) + } + } + } + } + }.padding(.horizontal, 20).padding(.bottom, 80) } + .scrollIndicators(.hidden) .zxPullToRefresh { await viewModel.refresh(knowledgeBaseId: knowledgeBaseId) } } } .navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).animatedTabBarHide() + .onChange(of: detailTab) { _, newTab in + if newTab == 1 && sources.isEmpty { Task { await loadSources() } } + } .toolbar { if isSelectMode { ToolbarItem(placement: .topBarLeading) { @@ -246,6 +298,20 @@ struct LibraryDetailPage: View { } .task { await viewModel.loadItems(knowledgeBaseId: knowledgeBaseId) } } + + private func loadSources() async { + isLoadingSources = true + do { sources = try await KnowledgeSourceService.shared.list(kbId: knowledgeBaseId) } catch {} + isLoadingSources = false + } + + private func deleteSource(_ src: KnowledgeSource) async { + do { + let _ = try await KnowledgeSourceService.shared.delete(kbId: knowledgeBaseId, id: src.id) + sources.removeAll { $0.id == src.id } + ZXToastManager.shared.success("已删除") + } catch { ZXToastManager.shared.error("删除失败") } + } } struct ZXCardRow: View { let icon: String; let title: String; let desc: String; let status: String; let c: Color var body: some View { HStack(spacing: 12) { Image(systemName: icon).font(.system(size: 18)).foregroundColor(c).frame(width: 40, height: 40).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 10)); VStack(alignment: .leading, spacing: 2) { Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 11)).foregroundColor(Color.zxF03) }; Spacer(); Text(status).font(.system(size: 10, weight: .semibold)).foregroundColor(c).padding(.horizontal, 8).padding(.vertical, 2).background(c.opacity(0.12)).clipShape(Capsule()) }