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:
parent
60d89e2bba
commit
fea5bca8a6
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user