From fea5bca8a65e7d9d0ebb83d8613d7c2ae4575872 Mon Sep 17 00:00:00 2001 From: wangdl Date: Thu, 28 May 2026 20:12:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(ios):=20IOS-M0-03=20Import=20Candidate=20?= =?UTF-8?q?=E5=80=99=E9=80=89=E7=9F=A5=E8=AF=86=E7=82=B9=E5=AE=A1=E6=89=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ImportCandidateService: listBySource/detail/update/accept/reject/batchAccept - 新增 ImportCandidate/BatchAcceptRequest 模型 - 新增 ImportReviewPage 审批页面: - 候选列表(置信度标签 + 标题 + 内容摘要) - 点击展开 → 接受/拒绝按钮 - 全部接受批量操作 - 空状态 + 加载态 - Route 新增 importReview(sourceId:) Co-Authored-By: Claude Opus 4.7 --- .../AIStudyApp/Core/Models/APIModels.swift | 16 +++ .../AIStudyApp/Core/Navigation/Route.swift | 4 + .../AIStudyApp/Core/Services/APIService.swift | 36 +++++ .../Features/Library/LibrarySubpages.swift | 129 ++++++++++++++++++ 4 files changed, 185 insertions(+) diff --git a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift index 6e22677..8ad98b6 100644 --- a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift +++ b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift @@ -456,6 +456,22 @@ struct ImportStatusResponse: Codable { let itemCount: Int? } +// MARK: - Import Candidate + +struct ImportCandidate: Codable, Identifiable { + let id: String + let sourceId: String? + let title: String? + let content: String? + let status: String? + let confidence: Double? + let createdAt: String? +} + +struct BatchAcceptRequest: Codable { + let sourceId: String +} + // MARK: - File Upload (COS presigned URL flow) struct FileUploadUrlRequest: Codable { diff --git a/AIStudyApp/AIStudyApp/Core/Navigation/Route.swift b/AIStudyApp/AIStudyApp/Core/Navigation/Route.swift index 5a464e6..9ff3ca4 100644 --- a/AIStudyApp/AIStudyApp/Core/Navigation/Route.swift +++ b/AIStudyApp/AIStudyApp/Core/Navigation/Route.swift @@ -22,6 +22,9 @@ enum Route: Hashable { case learningSession(taskTitle: String, taskType: String, taskColorHex: String) case studyHome + // Import + case importReview(sourceId: String) + // Profile case notificationList case settings @@ -60,6 +63,7 @@ extension Route { case .methodPreference: MethodPreferenceView() case .feedbackForm: FeedbackFormView() case .editProfile: EditProfilePage() + case .importReview(let sourceId): ImportReviewPage(sourceId: sourceId) } } } diff --git a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift index 762acde..de985b1 100644 --- a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift +++ b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift @@ -320,6 +320,42 @@ class DocumentImportService { } } +// MARK: - Import Candidate + +@MainActor +class ImportCandidateService { + static let shared = ImportCandidateService() + private let client = APIClient.shared + + func listBySource(sourceId: String) async throws -> [ImportCandidate] { + return try await client.request("/knowledge-sources/\(sourceId)/import-candidates") + } + + func detail(id: String) async throws -> ImportCandidate { + return try await client.request("/import-candidates/\(id)") + } + + func update(id: String, title: String?, content: String?) async throws -> ImportCandidate { + var body: [String: String] = [:] + if let t = title { body["title"] = t } + if let c = content { body["content"] = c } + return try await client.request("/import-candidates/\(id)", method: "PATCH", body: body) + } + + func accept(id: String) async throws -> GenericSuccessResponse { + return try await client.request("/import-candidates/\(id)/accept", method: "POST") + } + + func reject(id: String) async throws -> GenericSuccessResponse { + return try await client.request("/import-candidates/\(id)/reject", method: "POST") + } + + func batchAccept(sourceId: String) async throws -> GenericSuccessResponse { + let body = BatchAcceptRequest(sourceId: sourceId) + return try await client.request("/import-candidates/batch-accept", method: "POST", body: body) + } +} + // MARK: - Notifications @MainActor diff --git a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift index d3c39c4..6d03471 100644 --- a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift +++ b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift @@ -682,6 +682,135 @@ struct ImportPage: View { 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)) } +// MARK: - Import Review + +struct ImportReviewPage: View { + let sourceId: String + @Environment(\.dismiss) private var dismiss + @State private var candidates: [ImportCandidate] = [] + @State private var isLoading = true + @State private var isProcessing = false + @State private var errorMessage: String? + @State private var expandedId: String? + + var body: some View { + ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) { + if isLoading { + VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载候选...").font(.system(size: 13)).foregroundColor(Color.zxF04) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if candidates.isEmpty { + VStack(spacing: 12) { + Image(systemName: "checkmark.circle").font(.system(size: 36)).foregroundColor(Color.zxGreen) + Text("没有待审批的候选").font(.system(size: 14)).foregroundColor(Color.zxF04) + }.frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + VStack(spacing: 12) { + HStack { + Text("\(candidates.count) 个候选知识点").font(.system(size: 13, weight: .medium)).foregroundColor(Color.zxF04) + Spacer() + Button { + Task { await batchAccept() } + } label: { + Text("全部接受").font(.system(size: 13, weight: .semibold)).foregroundColor(Color.zxPrimary) + } + .disabled(isProcessing) + } + ForEach(candidates) { c in + VStack(spacing: 0) { + Button { + withAnimation { expandedId = expandedId == c.id ? nil : c.id } + } label: { + HStack(spacing: 12) { + if let conf = c.confidence { + Text("\(Int(conf * 100))%").font(.system(size: 10, weight: .bold)) + .foregroundColor(conf > 0.7 ? Color.zxGreen : Color.zxAmber) + .padding(.horizontal, 6).padding(.vertical, 2) + .background((conf > 0.7 ? Color.zxGreen : Color.zxAmber).opacity(0.12)) + .clipShape(Capsule()) + } + VStack(alignment: .leading, spacing: 4) { + Text(c.title ?? "无标题").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0).lineLimit(1) + if let content = c.content { + Text(content).font(.system(size: 12)).foregroundColor(Color.zxF04).lineLimit(2) + } + } + Spacer() + Image(systemName: expandedId == c.id ? "chevron.up" : "chevron.down").font(.system(size: 12)).foregroundColor(Color.zxF03) + } + .padding(12).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 14)) + } + .foregroundColor(.primary) + + if expandedId == c.id { + HStack(spacing: 8) { + Button { + Task { await rejectCandidate(c) } + } label: { + Label("拒绝", systemImage: "xmark").font(.system(size: 13, weight: .medium)) + .foregroundColor(Color.zxCoral).frame(maxWidth: .infinity).frame(height: 40) + .background(Color.zxCoral.opacity(0.1)).clipShape(RoundedRectangle(cornerRadius: 10)) + } + Button { + Task { await acceptCandidate(c) } + } label: { + Label("接受", systemImage: "checkmark").font(.system(size: 13, weight: .medium)) + .foregroundColor(Color.zxGreen).frame(maxWidth: .infinity).frame(height: 40) + .background(Color.zxGreen.opacity(0.1)).clipShape(RoundedRectangle(cornerRadius: 10)) + } + } + .padding(.horizontal, 12).padding(.top, 8) + } + } + } + } + .padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100) + } + .scrollIndicators(.hidden) + } + }} + .navigationTitle("候选审批").navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).animatedTabBarHide() + .disabled(isProcessing) + .task { await load() } + } + + private func load() async { + isLoading = true; errorMessage = nil + do { candidates = try await ImportCandidateService.shared.listBySource(sourceId: sourceId) } catch { errorMessage = error.localizedDescription } + isLoading = false + } + + private func acceptCandidate(_ c: ImportCandidate) async { + isProcessing = true + do { + let _ = try await ImportCandidateService.shared.accept(id: c.id) + candidates.removeAll { $0.id == c.id } + ZXToastManager.shared.success("已接受") + } catch { ZXToastManager.shared.error("操作失败") } + isProcessing = false + } + + private func rejectCandidate(_ c: ImportCandidate) async { + isProcessing = true + do { + let _ = try await ImportCandidateService.shared.reject(id: c.id) + candidates.removeAll { $0.id == c.id } + ZXToastManager.shared.success("已拒绝") + } catch { ZXToastManager.shared.error("操作失败") } + isProcessing = false + } + + private func batchAccept() async { + isProcessing = true + do { + let _ = try await ImportCandidateService.shared.batchAccept(sourceId: sourceId) + candidates.removeAll() + ZXToastManager.shared.success("全部接受") + } catch { ZXToastManager.shared.error("操作失败") } + isProcessing = false + } +} + struct EditKnowledgePage: View { let item: KnowledgeItem @State private var title: String; @State private var content: String