feat(ios): IOS-M0-04 Knowledge Source 知识源管理

- 新增 KnowledgeSourceService: list/detail/add/delete
- 新增 KnowledgeSource/AddSourceRequest 模型
- LibraryDetailPage 新增分段选择器:知识点 | 资料来源
- 资料来源 Tab 显示来源列表(类型图标/标题/解析状态/字数)
- 左滑删除资料来源
- 切换 Tab 自动加载数据

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-05-28 20:15:13 +08:00
parent fea5bca8a6
commit 6664612212
3 changed files with 112 additions and 0 deletions

View File

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

View File

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

View File

@ -140,6 +140,9 @@ struct LibraryDetailPage: View {
@State private var isSelectMode = false
@State private var selectedIds: Set<String> = []
@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()) }