feat(ios): IOS-M0-03 Import Candidate 候选知识点审批

- 新增 ImportCandidateService: listBySource/detail/update/accept/reject/batchAccept
- 新增 ImportCandidate/BatchAcceptRequest 模型
- 新增 ImportReviewPage 审批页面:
  - 候选列表(置信度标签 + 标题 + 内容摘要)
  - 点击展开 → 接受/拒绝按钮
  - 全部接受批量操作
  - 空状态 + 加载态
- Route 新增 importReview(sourceId:)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-05-28 20:12:01 +08:00
parent 60d89e2bba
commit fea5bca8a6
4 changed files with 185 additions and 0 deletions

View File

@ -456,6 +456,22 @@ struct ImportStatusResponse: Codable {
let itemCount: Int? 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) // MARK: - File Upload (COS presigned URL flow)
struct FileUploadUrlRequest: Codable { struct FileUploadUrlRequest: Codable {

View File

@ -22,6 +22,9 @@ enum Route: Hashable {
case learningSession(taskTitle: String, taskType: String, taskColorHex: String) case learningSession(taskTitle: String, taskType: String, taskColorHex: String)
case studyHome case studyHome
// Import
case importReview(sourceId: String)
// Profile // Profile
case notificationList case notificationList
case settings case settings
@ -60,6 +63,7 @@ extension Route {
case .methodPreference: MethodPreferenceView() case .methodPreference: MethodPreferenceView()
case .feedbackForm: FeedbackFormView() case .feedbackForm: FeedbackFormView()
case .editProfile: EditProfilePage() case .editProfile: EditProfilePage()
case .importReview(let sourceId): ImportReviewPage(sourceId: sourceId)
} }
} }
} }

View File

@ -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 // MARK: - Notifications
@MainActor @MainActor

View File

@ -682,6 +682,135 @@ struct ImportPage: View {
struct ZXImportRow: View { let icon: String; let title: String; let desc: String 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)) } 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 { struct EditKnowledgePage: View {
let item: KnowledgeItem let item: KnowledgeItem
@State private var title: String; @State private var content: String @State private var title: String; @State private var content: String