From 657e9cf2b4f7f012bca751f2b7d37e10aac0beaa Mon Sep 17 00:00:00 2001 From: wangdl Date: Fri, 29 May 2026 19:44:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(ios):=20IOS-M1-02=20=E7=9F=A5=E8=AF=86?= =?UTF-8?q?=E5=BA=93=E5=88=97=E8=A1=A8=E7=AD=9B=E9=80=89=20+=20=E7=BD=AE?= =?UTF-8?q?=E9=A1=B6=20+=20=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LibraryHomeView 新增 filter chips(全部/我的/已订阅/官方) - ZLibraryCard 显示置顶图标 + 公开标签 - LibraryViewModel 新增 currentFilter + fetchKBs 按类型加载 - KnowledgeBaseService 新增 listSubscribed() - KnowledgeBase 模型新增 isPinned/visibility/ownerType/isVerified Co-Authored-By: Claude Opus 4.7 --- .../AIStudyApp/Core/Models/APIModels.swift | 5 ++ .../AIStudyApp/Core/Services/APIService.swift | 7 +++ .../Features/Library/LibraryHomeView.swift | 63 +++++++++++++------ .../Features/Library/LibraryViewModel.swift | 55 +++++++++------- 4 files changed, 89 insertions(+), 41 deletions(-) diff --git a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift index b0ad0d4..0f75101 100644 --- a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift +++ b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift @@ -165,6 +165,11 @@ struct KnowledgeBase: Codable, Identifiable { let description: String? let coverKey: String? let coverUrl: String? + let coverType: String? + let visibility: String? + let isPinned: Bool? + let ownerType: String? + let isVerified: Bool? let status: String? let itemCount: Int? let lastStudiedAt: String? diff --git a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift index 59a53a6..c0584a7 100644 --- a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift +++ b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift @@ -122,6 +122,13 @@ class KnowledgeBaseService { return try await client.request("/knowledge-bases/\(id)", method: "PATCH", body: body) } + func listSubscribed(page: Int = 1, limit: Int = 20) async throws -> [KnowledgeBase] { + return try await client.request("/knowledge-bases/subscribed", queryItems: [ + URLQueryItem(name: "page", value: String(page)), + URLQueryItem(name: "limit", value: String(limit)), + ]) + } + func delete(id: String) async throws -> GenericSuccessResponse { return try await client.request("/knowledge-bases/\(id)", method: "DELETE") } diff --git a/AIStudyApp/AIStudyApp/Features/Library/LibraryHomeView.swift b/AIStudyApp/AIStudyApp/Features/Library/LibraryHomeView.swift index 0d5338c..ec807b0 100644 --- a/AIStudyApp/AIStudyApp/Features/Library/LibraryHomeView.swift +++ b/AIStudyApp/AIStudyApp/Features/Library/LibraryHomeView.swift @@ -16,31 +16,47 @@ struct LibraryHomeView: View { .frame(width: 36, height: 36).background(Color(hex:"#FFFFFF",opacity:0.05)) .clipShape(RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1)) - } - .accessibilityLabel("搜索知识库") + }.accessibilityLabel("搜索知识库") NavigationLink(value: Route.libraryCreate) { Image(systemName: "plus").font(.system(size: 18)).foregroundColor(.white) - .frame(width: 36, height: 36).background(ZXGradient.brand) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } - .accessibilityLabel("创建新知识库") - } - .padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 12) - HStack(spacing: 8) { Image(systemName: "magnifyingglass").font(.system(size: 16)).foregroundColor(Color.zxF03); TextField("搜索知识库或知识点…", text: $s).font(.system(size: 14)).tint(Color.zxPurple).accessibilityLabel("搜索知识库") } - .padding(.horizontal, 14).frame(height: 44).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 14)).padding(.horizontal, 20).padding(.bottom, 16) - .accessibilityHint("输入关键词搜索知识库或知识点") + .frame(width: 36, height: 36).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: 10)) + }.accessibilityLabel("创建新知识库") + }.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 12) + + // 搜索 + HStack(spacing: 8) { Image(systemName: "magnifyingglass").font(.system(size: 16)).foregroundColor(Color.zxF03); TextField("搜索知识库或知识点…", text: $s).font(.system(size: 14)).tint(Color.zxPurple) } + .padding(.horizontal, 14).frame(height: 44).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 14)).padding(.horizontal, 20).padding(.bottom, 12) + + // 筛选 chips + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(LibraryViewModel.LibraryFilter.allCases, id: \.rawValue) { f in + Button { + viewModel.currentFilter = f + Task { await viewModel.loadKnowledgeBases() } + } label: { + Text(f.rawValue).font(.system(size: 13, weight: .medium)) + .foregroundColor(viewModel.currentFilter == f ? Color.zxOnPrimary : Color.zxF05) + .padding(.horizontal, 14).padding(.vertical, 7) + .background(viewModel.currentFilter == f ? AnyView(ZXGradient.brand) : AnyView(Color.zxFill004)) + .clipShape(Capsule()) + .overlay(viewModel.currentFilter == f ? nil : Capsule().stroke(Color.zxBorder008, lineWidth: 1)) + } + } + }.padding(.horizontal, 20) + }.padding(.bottom, 12) + ScrollView { VStack(spacing: 12) { if viewModel.isLoading && viewModel.knowledgeBases.isEmpty { - VStack(spacing: 12) { ZXLoadingView(size: 36, lineWidth: 3); Text("加载中…").font(.system(size: 13)).foregroundColor(Color.zxF04) } - .frame(maxWidth: .infinity).padding(.top, 80) + VStack(spacing: 12) { ZXLoadingView(size: 36, lineWidth: 3); Text("加载中…").font(.system(size: 13)).foregroundColor(Color.zxF04) }.frame(maxWidth: .infinity).padding(.top, 80) } ForEach(viewModel.knowledgeBases) { kb in NavigationLink(value: Route.libraryDetail(knowledgeBaseId: kb.id)) { - ZLibraryCard(coverUrl: kb.coverUrl, name: kb.title, desc: kb.description ?? "", items: kb.itemCount ?? 0, last: lastStudiedText(kb.lastStudiedAt)) + ZLibraryCard(coverUrl: kb.coverUrl, name: kb.title, desc: kb.description ?? "", items: kb.itemCount ?? 0, last: lastStudiedText(kb.lastStudiedAt), isPinned: kb.isPinned ?? false, visibility: kb.visibility ?? "private", ownerType: kb.ownerType ?? "user") } } if viewModel.knowledgeBases.isEmpty && !viewModel.isLoading { - Text("还没有知识库,点击右上角 + 创建").font(.system(size: 14)).foregroundColor(Color.zxF04).padding(.top, 60) + Text(emptyText).font(.system(size: 14)).foregroundColor(Color.zxF04).padding(.top, 60) } if viewModel.hasMore { ZXLoadMoreFooter { await viewModel.loadMore() } @@ -54,15 +70,22 @@ struct LibraryHomeView: View { .navigationDestination(for: Route.self) { $0.destination } } + private var emptyText: String { + switch viewModel.currentFilter { + case .subscribed: return "还没有订阅任何知识库" + case .official: return "暂无官方知识库" + default: return "还没有知识库,点击右上角 + 创建" + } + } + private func lastStudiedText(_ iso: String?) -> String { guard let iso else { return "未学习" } return iso.prefix(10).description } } -struct ZLibraryCard: View { let coverUrl: String?; let name: String; let desc: String; let items: Int; let last: String +struct ZLibraryCard: View { let coverUrl: String?; let name: String; let desc: String; let items: Int; let last: String; let isPinned: Bool; let visibility: String; let ownerType: String var body: some View { VStack(spacing: 0) { HStack(spacing: 12) { - // 封面图 ZStack { RoundedRectangle(cornerRadius: 13).fill(Color.zxPurpleBG(0.12)).frame(width: 56, height: 56) if let url = coverUrl, let imageUrl = URL(string: url) { @@ -77,7 +100,11 @@ struct ZLibraryCard: View { let coverUrl: String?; let name: String; let desc: S } } VStack(alignment: .leading, spacing: 4) { - Text(name).font(.system(size: 16, weight: .bold)).foregroundColor(Color.zxF0) + HStack(spacing: 6) { + Text(name).font(.system(size: 16, weight: .bold)).foregroundColor(Color.zxF0) + if isPinned { Image(systemName: "pin.fill").font(.system(size: 10)).foregroundColor(Color.zxOrange) } + if visibility == "public" { Text("公开").font(.system(size: 9, weight: .semibold)).foregroundColor(Color.zxGreen).padding(.horizontal, 5).padding(.vertical, 1).background(Color.zxGreen.opacity(0.12)).clipShape(Capsule()) } + } if !desc.isEmpty { Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF04).lineLimit(1) } } Spacer() diff --git a/AIStudyApp/AIStudyApp/Features/Library/LibraryViewModel.swift b/AIStudyApp/AIStudyApp/Features/Library/LibraryViewModel.swift index 910efb6..c107a35 100644 --- a/AIStudyApp/AIStudyApp/Features/Library/LibraryViewModel.swift +++ b/AIStudyApp/AIStudyApp/Features/Library/LibraryViewModel.swift @@ -13,40 +13,32 @@ class LibraryViewModel: ObservableObject { private var currentPage = 1 private let pageSize = 20 + var currentFilter: LibraryFilter = .all + + enum LibraryFilter: String, CaseIterable { case all = "全部", mine = "我的", subscribed = "已订阅", official = "官方" } + func loadKnowledgeBases() async { - isLoading = true - errorMessage = nil - currentPage = 1 + isLoading = true; errorMessage = nil; currentPage = 1 do { - knowledgeBases = try await KnowledgeBaseService.shared.list(page: 1, limit: pageSize) + knowledgeBases = try await fetchKBs(page: 1) hasMore = knowledgeBases.count >= pageSize - } catch { - if knowledgeBases.isEmpty { errorMessage = "加载知识库失败" } - } + } catch { if knowledgeBases.isEmpty { errorMessage = "加载知识库失败" } } isLoading = false } func refresh() async { - isRefreshing = true - currentPage = 1 - do { - knowledgeBases = try await KnowledgeBaseService.shared.list(page: 1, limit: pageSize) - hasMore = knowledgeBases.count >= pageSize - } catch {} + isRefreshing = true; currentPage = 1 + do { knowledgeBases = try await fetchKBs(page: 1); hasMore = knowledgeBases.count >= pageSize } catch {} isRefreshing = false } func loadMore() async { guard !isLoadingMore, hasMore else { return } - isLoadingMore = true - currentPage += 1 + isLoadingMore = true; currentPage += 1 do { - let more = try await KnowledgeBaseService.shared.list(page: currentPage, limit: pageSize) - knowledgeBases.append(contentsOf: more) - hasMore = more.count >= pageSize - } catch { - currentPage -= 1 - } + let more = try await fetchKBs(page: currentPage) + knowledgeBases.append(contentsOf: more); hasMore = more.count >= pageSize + } catch { currentPage -= 1 } isLoadingMore = false } @@ -67,8 +59,25 @@ class LibraryViewModel: ObservableObject { _ = try await KnowledgeBaseService.shared.delete(id: id) knowledgeBases.removeAll { $0.id == id } ZXToastManager.shared.success("已删除") - } catch { - ZXToastManager.shared.error("删除失败") + } catch { ZXToastManager.shared.error("删除失败") } + } + + func togglePin(id: String) async { + do { + let _ = try await api.post("/knowledge-bases/\(id)/pin") + // reload after pin toggle + await loadKnowledgeBases() + } catch {} + } + + private var api: APIClient { APIClient.shared } + + private func fetchKBs(page: Int) async throws -> [KnowledgeBase] { + switch currentFilter { + case .all: return try await KnowledgeBaseService.shared.list(page: page, limit: pageSize) + case .mine: return try await KnowledgeBaseService.shared.list(page: page, limit: pageSize) // default is user's own + case .subscribed: return try await KnowledgeBaseService.shared.listSubscribed(page: page, limit: pageSize) + case .official: return [] // TODO: when backend supports ownerType=official } } }