wangdl 2610bcf7f9 feat: M-CHAT iOS ChatScope 入口 + AI View 对接
## ChatEntryContext 模型
- ChatScopeType enum (knowledgeBase/folder/material/knowledgeItem/global)
- ChatEntryContext struct + ChatScopeSnapshot
- ChatSession 更新 (新增 13 个 scope 字段)
- CreateSessionRequest/UpdateChatSessionRequest

## Route + Service
- Route.aiChat 从 knowledgeBaseId 改为 ChatEntryContext
- RagChatService.createSession 接入 open-or-create API
- listSessions 支持 scope 过滤
- 新增 updateSession (PATCH)

## 6 个入口全部接入
- 知识库详情 → knowledge_base scope
- 资料详情 → material scope
- 资料阅读页 → material scope
- 知识点详情 → knowledge_item scope
- 全局入口 → global scope

## AI Chat View
- open-or-create: load() 直接调 POST /sessions
- 顶部 scope 指示器 (scopeLabel + scopeIcon)
- 新对话按钮在当前 scope 下工作

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 17:41:27 +08:00

1374 lines
70 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import Combine
import PhotosUI
import UniformTypeIdentifiers
struct CreateLibraryPage: View {
@Environment(\.dismiss) private var dismiss
@State private var name = ""; @State private var desc = ""
@State private var isCreating = false; @State private var isUploadingCover = false
@State private var coverKey: String?; @State private var coverImage: UIImage?
@State private var showCoverPicker = false; @State private var coverPhotoItem: PhotosPickerItem?
@State private var showCoverSheet = false
var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
ScrollView { VStack(spacing: 20) {
//
VStack(alignment: .leading, spacing: 8) {
Text("封面图").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035)
Button { showCoverSheet = true } label: {
ZStack {
RoundedRectangle(cornerRadius: 14).fill(Color.zxFill004)
.overlay(RoundedRectangle(cornerRadius: 14).stroke(style: StrokeStyle(lineWidth: 1.5, dash: [6, 4])).foregroundColor(Color.zxBorder01))
.frame(width: 120, height: 120)
if let img = coverImage {
Image(uiImage: img).resizable().scaledToFill().frame(width: 120, height: 120).clipShape(RoundedRectangle(cornerRadius: 14))
} else {
VStack(spacing: 6) {
Image(systemName: "camera").font(.system(size: 22)).foregroundColor(Color.zxF04)
Text("上传").font(.system(size: 12)).foregroundColor(Color.zxF04)
}
}
if isUploadingCover { RoundedRectangle(cornerRadius: 14).fill(Color.black.opacity(0.4)).frame(width: 120, height: 120); ProgressView().tint(.white) }
}
}
.disabled(isUploadingCover)
}
.frame(maxWidth: .infinity, alignment: .leading)
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 2) { Text("知识库名称").font(.system(size: 12, weight: .semibold)); Text("*").foregroundColor(.red) }.foregroundColor(Color.zxF035)
TextField("例如:机器学习", text: $name).font(.system(size: 16)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 52).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
}
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 2) { Text("描述").font(.system(size: 12, weight: .semibold)); Text("*").foregroundColor(.red) }.foregroundColor(Color.zxF035)
TextEditor(text: $desc).frame(minHeight: 80).scrollContentBackground(.hidden).padding(8).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
}
Button {
guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!desc.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
isCreating = true
Task {
let kb = try? await KnowledgeBaseService.shared.create(
title: name, description: desc.isEmpty ? nil : desc, coverKey: coverKey
)
await MainActor.run {
isCreating = false
if kb != nil {
ZXToastManager.shared.success("知识库已创建")
dismiss()
} else {
ZXToastManager.shared.error("创建失败,请重试")
}
}
}
} label: {
HStack(spacing: 8) {
if isCreating { ProgressView().tint(.white) }
Text("创建").font(.system(size: 14, weight: .bold))
}
.foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52)
.background(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? AnyView(Color.zxHairlineStrong) : AnyView(ZXGradient.ctaPurple))
.clipShape(RoundedRectangle(cornerRadius: 16))
}
.disabled(isCreating || name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || desc.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}.padding(.horizontal, 20).padding(.top, 20) }.scrollIndicators(.hidden) }
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar)
.photosPicker(isPresented: $showCoverPicker, selection: $coverPhotoItem, matching: .images)
.onChange(of: coverPhotoItem) { _, item in
guard let item else { return }
Task { await uploadCover(item) }
}
.sheet(isPresented: $showCoverSheet) {
VStack(spacing: 0) {
RoundedRectangle(cornerRadius: 3).fill(Color.zxF03).frame(width: 36, height: 5).padding(.top, 12).padding(.bottom, 16)
Text("封面图").font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0).padding(.bottom, 20)
Button {
showCoverSheet = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { showCoverPicker = true }
} label: {
HStack(spacing: 12) {
Image("icon-camera").font(.system(size: 20)).foregroundColor(Color.zxPrimary).frame(width: 40, height: 40).background(Color.zxPrimarySoft).clipShape(RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading, spacing: 2) {
Text("从相册选择").font(.system(size: 16, weight: .medium)).foregroundColor(Color.zxF0)
Text("选择一张图片作为封面").font(.system(size: 12)).foregroundColor(Color.zxF04)
}
Spacer()
Image("icon-chevron-right").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(Color.zxF03)
}
.padding(.horizontal, 16).padding(.vertical, 14)
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14))
}
.foregroundColor(.primary)
.padding(.horizontal, 20)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.zxBg0)
.presentationDetents([.height(220)])
.presentationDragIndicator(.hidden)
}}
private func uploadCover(_ item: PhotosPickerItem) async {
isUploadingCover = true
defer { isUploadingCover = false; coverPhotoItem = nil }
guard let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) else { return }
let resized = resizeCoverImage(image, tw: 512, th: 512)
do {
let r = try await FileUploadService.shared.uploadImageWithKey(resized, filename: "cover_\(Int(Date().timeIntervalSince1970)).jpg")
coverKey = r.objectKey; coverImage = resized
} catch { ZXToastManager.shared.error("封面上传失败") }
}
private func resizeCoverImage(_ image: UIImage, tw: CGFloat, th: CGFloat) -> UIImage {
let tr = tw / th; let ir = image.size.width / image.size.height
let rect: CGRect
if ir > tr { let nw = image.size.height * tr; rect = CGRect(x: (image.size.width - nw)/2, y: 0, width: nw, height: image.size.height) }
else { let nh = image.size.width / tr; rect = CGRect(x: 0, y: (image.size.height - nh)/2, width: image.size.width, height: nh) }
guard let cg = image.cgImage?.cropping(to: rect) else { return image }
let r = UIGraphicsImageRenderer(size: CGSize(width: tw, height: th))
return r.image { _ in UIImage(cgImage: cg).draw(in: CGRect(origin: .zero, size: CGSize(width: tw, height: th))) }
}
}
struct LibraryDetailPage: View {
let knowledgeBaseId: String
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = LibraryDetailViewModel()
@State private var showDeleteConfirm = false
@State private var isDeleting = false
@State private var isSelectMode = false
@State private var selectedIds: Set<String> = []
@State private var showBatchDeleteConfirm = false
@State private var detailTab = 0
@State private var sortOption = 0
@State private var sources: [KnowledgeSource] = []
@State private var isLoadingSources = false
private var allSelected: Bool {
!viewModel.items.isEmpty && selectedIds.count == viewModel.items.count
}
var body: some View {
ZStack(alignment: .top) {
Color.zxBg0.ignoresSafeArea()
// Top gradient wash blue/purple fading to transparent
LinearGradient(
colors: [
Color.zxPurple.opacity(0.08),
Color.zxPurple.opacity(0.04),
Color.clear,
],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 220)
.ignoresSafeArea(edges: .top)
VStack(spacing: 0) {
// KB info header fixed, non-scrolling
if let kb = viewModel.knowledgeBase {
kbInfoHeader(kb)
.padding(.horizontal, 20).padding(.top, 8)
}
Picker("", selection: $detailTab) {
Text("知识点").tag(0)
Text("资料来源").tag(1)
}
.pickerStyle(.segmented)
.padding(.horizontal, 20).padding(.top, 12)
ScrollView {
VStack(spacing: 0) {
if detailTab == 0 {
if viewModel.isLoading && viewModel.items.isEmpty {
VStack(spacing: 12) {
ProgressView()
Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04)
}
.frame(maxWidth: .infinity).padding(.top, 80)
}
ForEach(viewModel.items) { item in
let icon = fileTypeIcon(for: item)
let type = fileTypeText(for: item)
let date = formatShortDate(item.updatedAt)
let progress = progressFor(item)
if isSelectMode {
Button {
if selectedIds.contains(item.id) { selectedIds.remove(item.id) }
else { selectedIds.insert(item.id) }
} label: {
HStack(spacing: 10) {
Image(systemName: selectedIds.contains(item.id) ? "checkmark.circle" : "circle")
.font(.system(size: 20))
.foregroundColor(selectedIds.contains(item.id) ? Color.zxPrimary : Color.zxF03)
itemRow(icon: icon, title: item.title, type: type, date: date, progress: progress)
}
}
.foregroundColor(.primary)
} else {
NavigationLink(value: Route.knowledgeDetail(item: item)) {
itemRow(icon: icon, title: item.title, type: type, date: date, progress: progress)
}
.contextMenu {
Button {
isSelectMode = true
selectedIds.insert(item.id)
} label: {
Label("多选", systemImage: "checkmark.circle")
}
Button {
// TODO: rename
} label: {
Label("重命名", image: "icon-pencil")
}
Button {
// TODO: move to folder
} label: {
Label("移动到", image: "icon-folder")
}
Divider()
Button(role: .destructive) {
Task {
await viewModel.batchDeleteItems(ids: [item.id])
await viewModel.refresh(knowledgeBaseId: knowledgeBaseId)
}
} label: {
Label("删除", image: "icon-trash")
}
}
}
}
if viewModel.items.isEmpty && !viewModel.isLoading {
Text("暂无知识点").font(.system(size: 14)).foregroundColor(Color.zxF03).padding(.top, 40)
}
if viewModel.hasMore {
ZXLoadMoreFooter { await viewModel.loadMore(knowledgeBaseId: knowledgeBaseId) }
}
} else {
if isLoadingSources {
VStack(spacing: 12) {
ProgressView().tint(Color.zxPurple)
Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04)
}.padding(.top, 80)
} else if sources.isEmpty {
Text("暂无资料来源").font(.system(size: 14)).foregroundColor(Color.zxF03).padding(.top, 40)
} else {
ForEach(sources) { src in
HStack(spacing: 12) {
Image(systemName: src.type == "file" ? "doc" : "link")
.font(.system(size: 18)).foregroundColor(Color.zxPurple)
.frame(width: 36, height: 36)
VStack(alignment: .leading, spacing: 2) {
Text(src.title ?? src.originalFilename ?? "未命名")
.font(.system(size: 15, weight: .medium)).foregroundColor(Color.zxF0).lineLimit(1)
if let len = src.textLength, len > 0 {
Text("\(len)")
.font(.system(size: 13)).foregroundColor(Color.zxF04)
}
}
Spacer()
Button {
Task { await deleteSource(src) }
} label: {
Image("icon-trash").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(Color.zxF03)
}
}
.padding(.vertical, 14)
.overlay(alignment: .bottom) {
Color.zxHairline.frame(height: 0.5)
}
}
}
}
}
.padding(.horizontal, 20).padding(.bottom, 80)
}
.scrollIndicators(.hidden)
.refreshable { await viewModel.refresh(knowledgeBaseId: knowledgeBaseId) }
}
}
.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar)
.onChange(of: detailTab) { _, newTab in
if newTab == 1 && sources.isEmpty { Task { await loadSources() } }
}
.toolbar {
if isSelectMode {
ToolbarItem(placement: .topBarLeading) {
Button("取消") { isSelectMode = false; selectedIds.removeAll() }
}
ToolbarItem(placement: .topBarTrailing) {
Button { selectedIds = allSelected ? [] : Set(viewModel.items.map(\.id)) } label: {
Text(allSelected ? "取消全选" : "全选").font(.system(size: 14))
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
showBatchDeleteConfirm = true
} label: {
Image("icon-trash").resizable().scaledToFit().frame(width: 18, height: 18).foregroundColor(selectedIds.isEmpty ? Color.zxF03 : Color.zxCoral)
}
.disabled(selectedIds.isEmpty)
}
} else {
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 4) {
Menu {
Button { sortOption = 0 } label: {
Label("默认排序", systemImage: sortOption == 0 ? "checkmark" : "")
}
Button { sortOption = 1 } label: {
Label("文件大小", systemImage: sortOption == 1 ? "checkmark" : "")
}
Button { sortOption = 2 } label: {
Label("创建日期", systemImage: sortOption == 2 ? "checkmark" : "")
}
Button { sortOption = 3 } label: {
Label("更新日期", systemImage: sortOption == 3 ? "checkmark" : "")
}
} label: {
Image(systemName: "arrow.up.arrow.down")
.font(.system(size: 16))
.foregroundColor(Color.zxF05)
}
Menu {
NavigationLink(value: Route.addKnowledge(knowledgeBaseId: knowledgeBaseId)) {
Label("添加知识点", image: "icon-plus")
}
Button {
// TODO: create folder
} label: {
Label("创建文件夹", image: "icon-folder")
}
NavigationLink(value: Route.quizList(knowledgeBaseId: knowledgeBaseId)) {
Label("答题测验", image: "icon-pencil")
}
Button {
isSelectMode = true
} label: {
Label("批量选择", systemImage: "checkmark.circle")
}
Divider()
Button {
// TODO: knowledge base management page
} label: {
Label("知识库管理", image: "icon-settings")
}
} label: {
Image(systemName: "ellipsis.circle")
.font(.system(size: 20))
.foregroundColor(Color.zxF05)
}
} // HStack
}
}
}
.alert("删除知识库", isPresented: $showDeleteConfirm) {
Button("取消", role: .cancel) {}
Button("删除", role: .destructive) {
isDeleting = true
Task {
await viewModel.deleteKnowledgeBase(id: knowledgeBaseId)
await MainActor.run { isDeleting = false; dismiss() }
}
}
} message: {
Text("删除后将无法恢复,包括其中的所有知识点。确定要删除吗?")
}
.alert("批量删除", isPresented: $showBatchDeleteConfirm) {
Button("取消", role: .cancel) {}
Button("删除 \(selectedIds.count)", role: .destructive) {
Task {
await viewModel.batchDeleteItems(ids: Array(selectedIds))
isSelectMode = false
selectedIds.removeAll()
}
}
} message: {
Text("确定要删除选中的 \(selectedIds.count) 个知识点吗?此操作不可恢复。")
}
.task { await viewModel.loadItems(knowledgeBaseId: knowledgeBaseId) }
.safeAreaInset(edge: .bottom) {
NavigationLink(value: Route.aiChat(context: ChatEntryContext(scopeType: .knowledgeBase, scopeId: knowledgeBaseId, scopeName: "知识库", parentKnowledgeBaseId: knowledgeBaseId, createdFrom: "knowledge_base_detail"))) {
HStack(spacing: 8) {
Image(systemName: "bubble.left.and.bubble.right").font(.system(size: 16))
Text("AI 对话").font(.system(size: 15, weight: .medium))
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(ZXGradient.brandPurple)
.clipShape(RoundedRectangle(cornerRadius: 14))
.padding(.horizontal, 20)
.padding(.bottom, 8)
}
.background(Color.zxBg0)
}
}
private func kbInfoHeader(_ kb: KnowledgeBase) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 16) {
// Cover image
ZStack {
RoundedRectangle(cornerRadius: 16)
.fill(Color.zxPurpleBG(0.12))
.frame(width: 80, height: 80)
if let coverUrl = kb.coverUrl, let url = resolvedCoverURL(coverUrl) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img):
img.resizable().scaledToFill()
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 16))
default:
Image("icon-books")
.font(.system(size: 34))
.foregroundColor(Color.zxPurple.opacity(0.4))
}
}
} else {
Image("icon-books")
.font(.system(size: 34))
.foregroundColor(Color.zxPurple.opacity(0.4))
}
}
VStack(alignment: .leading, spacing: 4) {
Text(kb.title)
.font(.system(size: 20, weight: .bold))
.foregroundColor(Color.zxF0)
if let desc = kb.description, !desc.isEmpty {
Text(desc)
.font(.system(size: 14))
.foregroundColor(Color.zxF04)
.lineLimit(2)
} else {
Text("暂无简介")
.font(.system(size: 14))
.foregroundColor(Color.zxF04)
}
}
}
// Info rows
VStack(alignment: .leading, spacing: 2) {
HStack {
infoLabel("来源", kb.ownerType == "official" ? "官方" : "个人")
.frame(maxWidth: .infinity, alignment: .leading)
if let created = kb.createdAt {
infoLabel("创建", String(created.prefix(10)))
.frame(maxWidth: .infinity, alignment: .leading)
}
}
HStack {
if let count = kb.itemCount {
infoLabel("文件", "\(count)")
.frame(maxWidth: .infinity, alignment: .leading)
}
if let updated = kb.updatedAt {
infoLabel("更新", String(updated.prefix(10)))
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.font(.system(size: 13))
}
}
private func infoLabel(_ label: String, _ value: String) -> some View {
HStack(spacing: 4) {
Text("\(label)")
.foregroundColor(Color.zxF03)
Text(value)
.foregroundColor(Color.zxF0)
}
}
private func resolvedCoverURL(_ urlString: String) -> URL? {
if urlString.hasPrefix("http") {
return URL(string: urlString)
}
return URL(string: "https://longde.cloud\(urlString)")
}
private func fileTypeText(for item: KnowledgeItem) -> String {
if let t = item.sourceType?.trimmingCharacters(in: .whitespaces), !t.isEmpty {
return t.uppercased()
}
let title = item.title.lowercased()
if title.hasSuffix(".pdf") { return "PDF" }
if title.hasSuffix(".md") { return "MD" }
if title.hasSuffix(".html") { return "HTML" }
if title.hasSuffix(".txt") { return "TXT" }
if title.hasSuffix(".png") || title.hasSuffix(".jpg") || title.hasSuffix(".jpeg") { return "图片" }
return "文件"
}
private func fileTypeIcon(for item: KnowledgeItem) -> String {
let t = item.sourceType?.lowercased() ?? ""
let title = item.title.lowercased()
if t.contains("pdf") || title.hasSuffix(".pdf") { return "doc.richtext" }
if t.contains("markdown") || title.hasSuffix(".md") { return "doc.plaintext" }
if t.contains("html") || t.contains("code") || title.hasSuffix(".html") { return "doc.text" }
if t.contains("image") || title.hasSuffix(".png") || title.hasSuffix(".jpg") { return "photo" }
return "doc"
}
private func itemRow(icon: String, title: String, type: String, date: String, progress: CGFloat) -> some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 18))
.foregroundColor(Color.zxPurple)
.frame(width: 36, height: 36)
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.zxF0)
.lineLimit(1)
HStack(spacing: 16) {
HStack(spacing: 3) {
Text("类型:")
.foregroundColor(Color.zxF03)
Text(type)
.foregroundColor(Color.zxF04)
}
if !date.isEmpty {
HStack(spacing: 3) {
Text("创建:")
.foregroundColor(Color.zxF03)
Text(date)
.foregroundColor(Color.zxF04)
}
}
HStack(spacing: 3) {
Text("学习:")
.foregroundColor(Color.zxF03)
Text(formatDuration(progress))
.foregroundColor(Color.zxF04)
}
}
.font(.system(size: 12))
}
Spacer()
Image("icon-chevron-right")
.resizable().scaledToFit().frame(width: 14, height: 14)
.foregroundColor(Color.zxF03)
}
.padding(.vertical, 14)
.overlay(alignment: .bottom) {
Color.zxHairline.frame(height: 0.5)
}
}
private func formatShortDate(_ iso: String?) -> String {
guard let iso else { return "" }
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let date = formatter.date(from: iso) ?? {
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: iso)
}()
guard let date else { return "" }
let cal = Calendar.current
if cal.isDate(date, equalTo: Date(), toGranularity: .year) {
let df = DateFormatter()
df.dateFormat = "MM/dd"
return df.string(from: date)
}
let df = DateFormatter()
df.dateFormat = "yyyy/MM/dd"
return df.string(from: date)
}
private func formatDuration(_ progress: CGFloat) -> String {
let totalSeconds = Int(progress * 1800) // up to 30 min
let h = totalSeconds / 3600
let m = (totalSeconds % 3600) / 60
let s = totalSeconds % 60
if h > 0 {
return String(format: "%d:%02d:%02d", h, m, s)
}
return String(format: "%02d:%02d", m, s)
}
private func progressFor(_ item: KnowledgeItem) -> CGFloat {
if item.status == "completed" { return 1.0 }
if item.status == "active" { return 0.5 }
return 0
}
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: 12)).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()) }
.padding(14).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder006, lineWidth: 1)) }
}
struct AddKnowledgePage: View {
let knowledgeBaseId: String
@Environment(\.dismiss) private var dismiss
@State private var title = ""
@State private var content = ""
@State private var sourceType: SourceType = .file
@State private var selectedFiles: [SelectedFile] = []
@State private var isUploading = false
@State private var isSaving = false
@State private var showFilePicker = false
@State private var showPhotoPicker = false
@State private var selectedPhotoItems: [PhotosPickerItem] = []
struct SelectedFile: Identifiable { let id = UUID(); let name: String; let data: Data; let icon: String; let size: String }
enum SourceType: String, CaseIterable { case manual = "手写", file = "文件" }
var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
ScrollView { VStack(spacing: 16) {
//
VStack(alignment: .leading, spacing: 8) {
Text("内容来源").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035)
Picker("", selection: $sourceType) {
ForEach(SourceType.allCases, id: \.self) { t in Text(t.rawValue).tag(t) }
}
.pickerStyle(.segmented)
}
//
switch sourceType {
case .manual:
VStack(alignment: .leading, spacing: 8) {
Text("标题").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035)
TextField("输入知识点标题", text: $title).font(.system(size: 16)).tint(Color.zxPurple)
.padding(.horizontal, 16).frame(height: 52)
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
}
VStack(alignment: .leading, spacing: 8) {
Text("内容").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035)
TextEditor(text: $content)
.frame(minHeight: 200).scrollContentBackground(.hidden).padding(12)
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
}
case .file:
VStack(spacing: 10) {
HStack(spacing: 8) {
Button { showFilePicker = true } label: {
HStack {
Image("icon-plus")
Text("选择文件")
}
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.zxPrimary)
.frame(maxWidth: .infinity).frame(height: 48)
.background(Color.zxPrimarySoft)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
Button { showPhotoPicker = true } label: {
HStack {
Image("icon-camera")
Text("图片")
}
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.zxAccent)
.frame(maxWidth: .infinity).frame(height: 48)
.background(Color.zxAccent.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
//
if !selectedFiles.isEmpty {
VStack(spacing: 6) {
ForEach(selectedFiles) { f in
HStack(spacing: 8) {
Image(systemName: f.icon).foregroundColor(Color.zxGreen)
Text(f.name).font(.system(size: 14)).foregroundColor(Color.zxF0).lineLimit(1)
Spacer()
Text(f.size).font(.system(size: 12)).foregroundColor(Color.zxF04)
Button {
selectedFiles.removeAll { $0.id == f.id }
} label: {
Image(systemName: "xmark.circle").font(.system(size: 16)).foregroundColor(Color.zxF04)
}
}
.padding(.horizontal, 12).padding(.vertical, 8)
.background(Color.zxFill004)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
HStack {
Image(systemName: "info.circle").font(.system(size: 12))
Text("支持多选,每个文件生成一个知识点")
}
.font(.system(size: 12)).foregroundColor(Color.zxF04)
}
if isUploading {
HStack(spacing: 8) {
ProgressView()
Text("上传中 \(currentUploadIndex)/\(selectedFiles.count)...").font(.system(size: 14)).foregroundColor(Color.zxF04)
}
}
}
//
Button {
Task { await save() }
} label: {
HStack(spacing: 8) {
if isSaving { ProgressView().tint(.white) }
Text(sourceType == .file && selectedFiles.count > 1 ? "批量添加 (\(selectedFiles.count) 个)" : "保存").font(.system(size: 14, weight: .bold))
}
.foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52)
.background(canSave ? AnyView(ZXGradient.ctaPurple) : AnyView(Color.zxHairlineStrong))
.clipShape(RoundedRectangle(cornerRadius: 16))
}
.disabled(!canSave || isSaving)
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80) }.scrollIndicators(.hidden) }
}
.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar)
.fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.plainText, .pdf, .image], allowsMultipleSelection: true) { result in
if case .success(let urls) = result { handleFiles(urls) }
}
.photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItems, maxSelectionCount: 10, matching: .images)
.onChange(of: selectedPhotoItems) { _, items in
guard !items.isEmpty else { return }
Task { await handlePhotos(items) }
}
}
// MARK: - File handling
@State private var currentUploadIndex = 0
private var canSave: Bool {
switch sourceType {
case .manual: return !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
case .file: return !selectedFiles.isEmpty && !isUploading
}
}
private func handleFiles(_ urls: [URL]) {
for url in urls {
guard url.startAccessingSecurityScopedResource() else { continue }
defer { url.stopAccessingSecurityScopedResource() }
guard let data = try? Data(contentsOf: url) else { continue }
let name = url.lastPathComponent
selectedFiles.append(SelectedFile(
name: name,
data: data,
icon: fileIcon(name: name),
size: formatSize(data.count)
))
}
}
private func handlePhotos(_ items: [PhotosPickerItem]) async {
for item in items {
guard let data = try? await item.loadTransferable(type: Data.self) else { continue }
let compressed: Data
if let image = UIImage(data: data), data.count > 2 * 1024 * 1024 {
compressed = image.jpegData(compressionQuality: 0.6) ?? data
} else {
compressed = data
}
let name = "image_\(Int(Date().timeIntervalSince1970))_\(selectedFiles.count).jpg"
selectedFiles.append(SelectedFile(
name: name,
data: compressed,
icon: "photo",
size: formatSize(compressed.count)
))
}
selectedPhotoItems = []
}
private func save() async {
isSaving = true
defer { isSaving = false }
switch sourceType {
case .manual:
do {
_ = try await KnowledgeItemService.shared.create(
knowledgeBaseId: knowledgeBaseId,
title: title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "手写笔记" : title,
content: content,
itemType: "manual"
)
ZXToastManager.shared.success("知识点已添加")
dismiss()
} catch {
ZXToastManager.shared.error("添加失败: \(error.localizedDescription)")
}
case .file:
isUploading = true
defer { isUploading = false }
var successCount = 0
let total = selectedFiles.count
for (i, file) in selectedFiles.enumerated() {
currentUploadIndex = i + 1
do {
let mime = mimeType(name: file.name)
let ext = file.name.lowercased()
let itemType = ext.hasSuffix(".md") || ext.hasSuffix(".markdown") ? "markdown"
: ext.hasSuffix(".txt") ? "text"
: ext.hasSuffix(".pdf") ? "pdf"
: ext.hasSuffix(".jpg") || ext.hasSuffix(".jpeg") || ext.hasSuffix(".png") || ext.hasSuffix(".heic") ? "image"
: "file"
let fileId: String
if let image = UIImage(data: file.data) {
fileId = try await FileUploadService.shared.uploadImage(image, filename: file.name)
} else {
fileId = try await FileUploadService.shared.uploadData(file.data, filename: file.name, mimeType: mime)
}
let downloadUrl = try await FileUploadService.shared.getDownloadUrl(fileId: fileId)
//
let itemTitle: String
if !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
itemTitle = total == 1 ? title : "\(title) (\(i+1))"
} else {
itemTitle = file.name
}
_ = try await KnowledgeItemService.shared.create(
knowledgeBaseId: knowledgeBaseId,
title: itemTitle,
content: downloadUrl,
itemType: itemType
)
successCount += 1
} catch {
ZXToastManager.shared.error("\(file.name)」上传失败")
}
}
if successCount == total {
ZXToastManager.shared.success("\(total) 个知识点已添加")
dismiss()
} else if successCount > 0 {
ZXToastManager.shared.success("\(successCount)/\(total) 个知识点已添加")
dismiss()
}
}
}
private func fileIcon(name: String) -> String {
let ext = name.lowercased()
if ext.hasSuffix(".md") || ext.hasSuffix(".markdown") { return "doc.richtext" }
if ext.hasSuffix(".txt") { return "doc.plaintext" }
if ext.hasSuffix(".pdf") { return "doc" }
if ext.hasSuffix(".jpg") || ext.hasSuffix(".jpeg") || ext.hasSuffix(".png") || ext.hasSuffix(".heic") { return "photo" }
return "doc"
}
private func formatSize(_ size: Int) -> String {
if size < 1024 { return "\(size) B" }
if size < 1024 * 1024 { return "\(size / 1024) KB" }
return String(format: "%.1f MB", Double(size) / 1024 / 1024)
}
private func mimeType(name: String) -> String {
let ext = name.lowercased()
if ext.hasSuffix(".md") || ext.hasSuffix(".markdown") { return "text/markdown" }
if ext.hasSuffix(".txt") { return "text/plain" }
if ext.hasSuffix(".pdf") { return "application/pdf" }
if ext.hasSuffix(".jpg") || ext.hasSuffix(".jpeg") { return "image/jpeg" }
if ext.hasSuffix(".png") { return "image/png" }
if ext.hasSuffix(".heic") { return "image/heic" }
return "application/octet-stream"
}
}
// MARK: - Knowledge Detail ViewModel
@MainActor
final class KnowledgeDetailViewModel: ObservableObject {
@Published var localPath: String?
@Published var detectedType: MaterialType?
@Published var loadState: LoadState = .loading
enum LoadState { case loading, ready, error(String) }
let item: KnowledgeItem
init(item: KnowledgeItem) { self.item = item }
var isURL: Bool {
guard let c = item.content else { return false }
return c.hasPrefix("http://") || c.hasPrefix("https://")
}
func load() async {
print("[KD_DETAIL] load() start — item.id=\(item.id), title=\(item.title)")
print("[KD_DETAIL] isURL=\(isURL), content prefix=\(item.content?.prefix(200) ?? "nil")")
guard isURL, let urlStr = item.content, let url = URL(string: urlStr) else {
// Not a URL plain text content, ready immediately
let contentLen = item.content?.count ?? 0
print("[KD_DETAIL] plain text path — content length=\(contentLen), setting .ready")
loadState = .ready
return
}
loadState = .loading
let ext = url.pathExtension.isEmpty ? "bin" : url.pathExtension.lowercased()
let localURL = FileManager.default.temporaryDirectory
.appendingPathComponent("kd_\(item.id).\(ext)")
print("[KD_DETAIL] URL download — ext=\(ext), local=\(localURL.path)")
// Check cached file skip if it's a COS error XML
if FileManager.default.fileExists(atPath: localURL.path) {
let fileSize = (try? FileManager.default.attributesOfItem(atPath: localURL.path)[.size] as? Int) ?? -1
// Detect COS error responses (small XML files < 2KB)
if fileSize > 0 && fileSize < 2048 {
if let content = try? String(contentsOfFile: localURL.path, encoding: .utf8),
content.contains("<Error>"), content.contains("AccessDenied") {
print("[KD_DETAIL] cached file is COS error, deleting and re-fetching...")
try? FileManager.default.removeItem(at: localURL)
}
}
if FileManager.default.fileExists(atPath: localURL.path) {
print("[KD_DETAIL] file cached — size=\(fileSize), type=\(typeFromExtension(ext))")
localPath = localURL.path
detectedType = typeFromExtension(ext)
loadState = .ready
return
}
}
// Re-fetch item from API to get a fresh signed URL
print("[KD_DETAIL] re-fetching item from API for fresh URL...")
let freshItem = try? await KnowledgeItemService.shared.detail(id: item.id)
let downloadURL: URL
if let fresh = freshItem, let freshStr = fresh.content, let freshURL = URL(string: freshStr) {
print("[KD_DETAIL] got fresh URL")
downloadURL = freshURL
} else {
print("[KD_DETAIL] re-fetch failed, using original URL")
downloadURL = url
}
do {
let (data, _) = try await URLSession.shared.data(from: downloadURL)
print("[KD_DETAIL] download done — size=\(data.count)")
// If it's a COS error XML, show error
if data.count < 2048, let text = String(data: data, encoding: .utf8), text.contains("<Error>") {
print("[KD_DETAIL] downloaded content is an error XML: \(text.prefix(200))")
loadState = .error("文件访问已过期,请刷新后重试")
return
}
try data.write(to: localURL)
localPath = localURL.path
detectedType = typeFromExtension(ext)
print("[KD_DETAIL] saved — path=\(localURL.path), type=\(String(describing: detectedType))")
loadState = .ready
} catch {
print("[KD_DETAIL] download ERROR — \(error.localizedDescription)")
loadState = .error(error.localizedDescription)
}
}
private func typeFromExtension(_ ext: String) -> MaterialType {
switch ext {
case "md", "markdown": return .markdown
case "txt", "text": return .text
case "pdf": return .pdf
case "png", "jpg", "jpeg", "webp", "gif": return .image
case "doc", "docx": return .word
case "xls", "xlsx": return .excel
case "ppt", "pptx": return .powerPoint
case "epub": return .epub
default: return .unknown
}
}
}
// MARK: - Knowledge Detail Page
struct KnowledgeDetailPage: View {
let item: KnowledgeItem
@StateObject private var vm: KnowledgeDetailViewModel
@State private var showQuickLook = false
@State private var showNoteSheet = false
init(item: KnowledgeItem) {
self.item = item
_vm = StateObject(wrappedValue: KnowledgeDetailViewModel(item: item))
}
var body: some View {
Group {
switch vm.loadState {
case .loading:
VStack(spacing: 12) {
ProgressView().tint(Color.zxPrimary)
Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04)
}.frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.zxCanvas)
case .error(let msg):
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle").font(.system(size: 40)).foregroundColor(Color.zxCoral)
Text(msg).font(.system(size: 14)).foregroundColor(Color.zxF04)
// Fallback: show summary or raw text
if let summary = item.summary, !summary.isEmpty {
ScrollView { Text(summary).font(.system(size: 14)).foregroundColor(Color.zxF0).lineSpacing(6).padding(20) }
}
Button("重试") { Task { await vm.load() } }
.font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxPrimary)
.padding(.horizontal, 24).padding(.vertical, 10)
.background(Color.zxPrimarySoft).clipShape(RoundedRectangle(cornerRadius: 10))
}.frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.zxCanvas)
case .ready:
if let path = vm.localPath, let mt = vm.detectedType {
MaterialReaderView(materialId: item.id, filePath: path, materialType: mt, title: item.title, showQuickLook: $showQuickLook, showNoteSheet: $showNoteSheet)
} else if let content = item.content {
// Plain text content render directly
ZStack { Color.zxCanvas.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: 12) {
if let summary = item.summary, !summary.isEmpty {
Text(summary).font(.system(size: 14)).foregroundColor(Color.zxF0).lineSpacing(6)
.padding(16).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14))
}
Text(content).font(.system(size: 14)).foregroundColor(Color.zxF0).lineSpacing(6)
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
}.scrollIndicators(.hidden)
}
}
}
}
.navigationTitle(item.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar(.hidden, for: .tabBar)
.toolbarBackground(.hidden, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
NavigationLink(value: Route.aiChat(context: ChatEntryContext(scopeType: .knowledgeItem, scopeId: item.id, scopeName: item.title, parentKnowledgeBaseId: item.knowledgeBaseId, createdFrom: "knowledge_item_detail"))) {
Label("AI 对话", systemImage: "bubble.left.and.bubble.right")
}
NavigationLink(value: Route.studyHome) {
Label("学习", systemImage: "arrow.triangle.2.circlepath")
}
NavigationLink(value: Route.editKnowledge(item: item)) {
Label("编辑", systemImage: "pencil")
}
if let mt = vm.detectedType {
let pm = previewMode(for: mt)
if pm == .nativeReader {
Button { showNoteSheet = true } label: {
Label("笔记", systemImage: "square.and.pencil")
}
}
if pm == .platformPreview {
Button { showQuickLook = true } label: {
Label("预览", systemImage: "eye")
}
}
}
} label: {
Image(systemName: "ellipsis.circle")
.font(.system(size: 16))
.foregroundColor(Color.zxF05)
}
}
}
.task { await vm.load() }
}
}
struct ZXChip: View { let text: String; let color: Color
var body: some View { Text(text).font(.system(size: 10, weight: .semibold)).foregroundColor(color).padding(.horizontal, 8).padding(.vertical, 2).background(color.opacity(0.12)).clipShape(Capsule()) }
}
struct ImportPage: View {
@Environment(\.dismiss) private var dismiss
@State private var showFilePicker = false
@State private var showPhotoPicker = false
@State private var showLinkSheet = false
@State private var linkURL = ""
@State private var isImporting = false
@State private var kbId: String?
@State private var statusMessage = ""; @State private var importError: String?
@State private var selectedPhotoItem: PhotosPickerItem?
var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
ScrollView { VStack(spacing: 12) {
if let error = importError {
HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle").foregroundColor(.red); Text(error).font(.system(size: 14)).foregroundColor(.red) }
.padding(12)
}
if !statusMessage.isEmpty {
HStack(spacing: 8) { ProgressView(); Text(statusMessage).font(.system(size: 14)).foregroundColor(Color.zxF04) }
.padding(12)
}
Button { showFilePicker = true } label: {
ZXImportRow(icon: "doc.text", title: "文件导入", desc: "支持 PDF、Word、Markdown、TXT")
}
Button { showPhotoPicker = true } label: {
ZXImportRow(icon: "photo.on.rectangle", title: "相册导入", desc: "从相册选择截图或图片AI 自动识别文字")
}
Button { showLinkSheet = true } label: {
ZXImportRow(icon: "link", title: "链接导入", desc: "粘贴网页链接,自动提取内容")
}
}.padding(.horizontal, 20).padding(.top, 8) }.scrollIndicators(.hidden) }
}
.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar)
.disabled(isImporting)
.task { do { let kbs = try await KnowledgeBaseService.shared.list(page: 1, limit: 1); kbId = kbs.first?.id } catch {} }
.fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.pdf, .plainText], allowsMultipleSelection: true) { result in
if case .success(let urls) = result { Task { await handleFiles(urls) } }
}
.photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItem, matching: .images)
.onChange(of: selectedPhotoItem) { _, item in
guard let item else { return }
Task { await handlePhoto(item) }
}
.alert("链接导入", isPresented: $showLinkSheet) {
TextField("输入网页 URL", text: $linkURL)
Button("导入") { Task { await handleLink() } }
Button("取消", role: .cancel) {}
} message: { Text("输入要导入的网页链接地址") }
}
private func ensureKB() async -> String? {
if let id = kbId { return id }
do { let kbs = try await KnowledgeBaseService.shared.list(page: 1, limit: 1); kbId = kbs.first?.id; return kbId } catch { return nil }
}
private func handleFiles(_ urls: [URL]) async {
guard let kb = await ensureKB() else { importError = "请先创建一个知识库"; return }
isImporting = true; importError = nil
for url in urls {
guard url.startAccessingSecurityScopedResource() else { continue }
defer { url.stopAccessingSecurityScopedResource() }
guard let data = try? Data(contentsOf: url) else { continue }
let name = url.lastPathComponent
statusMessage = "上传 \(name)..."
do {
let mime = url.pathExtension.lowercased() == "pdf" ? "application/pdf" : "text/plain"
let _ = try await FileUploadService.shared.uploadData(data, filename: name, mimeType: mime)
statusMessage = "开始导入 \(name)..."
let _ = try await DocumentImportService.shared.create(knowledgeBaseId: kb, fileName: name, sourceType: "file")
statusMessage = "\(name) 导入任务已创建"
} catch { importError = "\(name) 导入失败: \(error.localizedDescription)" }
}
isImporting = false; statusMessage = ""
if importError == nil { ZXToastManager.shared.success("导入完成"); dismiss() }
}
private func handlePhoto(_ item: PhotosPickerItem) async {
guard let kb = await ensureKB() else { importError = "请先创建一个知识库"; return }
isImporting = true; importError = nil; selectedPhotoItem = nil
guard let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) else { isImporting = false; return }
let name = "photo_\(Int(Date().timeIntervalSince1970)).jpg"
statusMessage = "上传图片..."
do {
let _ = try await FileUploadService.shared.uploadImage(image, filename: name)
statusMessage = "开始识别..."
let _ = try await DocumentImportService.shared.create(knowledgeBaseId: kb, fileName: name, sourceType: "file")
ZXToastManager.shared.success("图片已提交导入")
dismiss()
} catch { importError = "导入失败: \(error.localizedDescription)" }
isImporting = false; statusMessage = ""
}
private func handleLink() async {
guard !linkURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
guard let kb = await ensureKB() else { importError = "请先创建一个知识库"; return }
isImporting = true; importError = nil
do {
let _ = try await DocumentImportService.shared.create(knowledgeBaseId: kb, sourceType: "link", rawText: linkURL)
ZXToastManager.shared.success("链接已提交导入")
dismiss()
} catch { importError = "导入失败: \(error.localizedDescription)" }
isImporting = false
}
}
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)
; VStack(alignment: .leading, spacing: 2) { Text(title).font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF04) }; Spacer(); Image("icon-chevron-right").resizable().scaledToFit().frame(width: 16, height: 16).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: 14)).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: 14, weight: .medium)).foregroundColor(Color.zxF04)
Spacer()
Button {
Task { await batchAccept() }
} label: {
Text("全部接受").font(.system(size: 14, 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: 14, weight: .medium))
.foregroundColor(Color.zxCoral).frame(maxWidth: .infinity).frame(height: 40)
}
Button {
Task { await acceptCandidate(c) }
} label: {
Label("接受", systemImage: "checkmark").font(.system(size: 14, weight: .medium))
.foregroundColor(Color.zxGreen).frame(maxWidth: .infinity).frame(height: 40)
}
}
.padding(.horizontal, 12).padding(.top, 8)
}
}
}
}
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
}
.scrollIndicators(.hidden)
}
}}
.navigationTitle("候选审批").navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar)
.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
init(item: KnowledgeItem) {
self.item = item
_title = State(initialValue: item.title)
_content = State(initialValue: item.content ?? "")
}
var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
ScrollView { VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 8) { Text("标题").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextField("", text: $title).font(.system(size: 16)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 52).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) }
VStack(alignment: .leading, spacing: 8) { Text("内容").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextEditor(text: $content).frame(minHeight: 200).scrollContentBackground(.hidden).padding(12).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) }
Button {
Task { _ = try? await KnowledgeItemService.shared.update(id: item.id, title: title, content: content, summary: nil) }
} label: { Text("保存修改").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) }
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 80) }.scrollIndicators(.hidden) }
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar)
}
}