wangdl 7f252b48f0 fix(ios): TabBar 隐藏改回直接 .toolbar(.hidden, for: .tabBar)
- 移除 AnimatedTabBarHide 环境值动画系统
- 所有子页面统一使用 .toolbar(.hidden, for: .tabBar)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 10:52:32 +08:00

917 lines
51 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 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: "icon-camera").font(.system(size: 22)).foregroundColor(Color.zxF04)
Text("上传").font(.system(size: 11)).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: 15)).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(systemName: "photo.on.rectangle").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: 15, 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 sources: [KnowledgeSource] = []
@State private var isLoadingSources = false
private var allSelected: Bool {
!viewModel.items.isEmpty && selectedIds.count == viewModel.items.count
}
var body: some View {
ZStack {
Color.zxBg0.ignoresSafeArea()
VStack(spacing: 0) {
Picker("", selection: $detailTab) {
Text("知识点").tag(0)
Text("资料来源").tag(1)
}
.pickerStyle(.segmented)
.padding(.horizontal, 20).padding(.top, 8)
ScrollView {
VStack(spacing: 12) {
if detailTab == 0 {
if viewModel.isLoading && viewModel.items.isEmpty {
VStack(spacing: 12) {
ZXLoadingView(size: 36, lineWidth: 3)
Text("加载中…").font(.system(size: 13)).foregroundColor(Color.zxF04)
}
.frame(maxWidth: .infinity).padding(.top, 80)
}
ForEach(viewModel.items) { item in
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.fill" : "circle")
.font(.system(size: 20))
.foregroundColor(selectedIds.contains(item.id) ? Color.zxPrimary : Color.zxF03)
ZXCardRow(icon: "doc.text", title: item.title, desc: item.summary ?? item.content ?? "", status: item.status ?? "active", c: Color.zxGreen)
}
}
.foregroundColor(.primary)
} else {
NavigationLink(value: Route.knowledgeDetail(item: item)) {
ZXCardRow(icon: "doc.text", title: item.title, desc: item.summary ?? item.content ?? "", status: item.status ?? "active", c: Color.zxGreen)
}
}
}
if viewModel.items.isEmpty && !viewModel.isLoading {
Text("暂无知识点").font(.system(size: 13)).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: 13)).foregroundColor(Color.zxF04)
}.padding(.top, 80)
} else if sources.isEmpty {
Text("暂无资料来源").font(.system(size: 13)).foregroundColor(Color.zxF03).padding(.top, 40)
} else {
ForEach(sources) { src in
HStack(spacing: 12) {
Image(systemName: src.type == "file" ? "doc.fill" : "link")
.font(.system(size: 18)).foregroundColor(Color.zxPurple)
.frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 2) {
Text(src.title ?? src.originalFilename ?? "未命名").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0).lineLimit(1)
HStack(spacing: 6) {
Text(src.parseStatus ?? "pending").font(.system(size: 10, weight: .semibold))
.foregroundColor(src.parseStatus == "completed" ? Color.zxGreen : Color.zxAmber)
.padding(.horizontal, 6).padding(.vertical, 1)
.background((src.parseStatus == "completed" ? Color.zxGreen : Color.zxAmber).opacity(0.12)).clipShape(Capsule())
if let len = src.textLength, len > 0 { Text("\(len)").font(.system(size: 10)).foregroundColor(Color.zxF04) }
}
}
Spacer()
Button {
Task { await deleteSource(src) }
} label: {
Image("icon-trash").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(Color.zxF03)
}
}
.padding(12).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 14))
}
}
}
}
.padding(.horizontal, 20).padding(.bottom, 80)
}
.scrollIndicators(.hidden)
.zxPullToRefresh { 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: 16, height: 16).foregroundColor(selectedIds.isEmpty ? Color.zxF03 : Color.zxCoral)
}
.disabled(selectedIds.isEmpty)
}
} else {
ToolbarItem(placement: .topBarLeading) {
Button { showDeleteConfirm = true } label: {
Image("icon-trash").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(Color.zxF03)
}
}
ToolbarItem(placement: .topBarTrailing) {
NavigationLink(value: Route.quizList(knowledgeBaseId: knowledgeBaseId)) {
Image(systemName: "questionmark.circle").font(.system(size: 16)).foregroundColor(Color.zxF05)
}
}
ToolbarItem(placement: .topBarTrailing) {
NavigationLink(value: Route.addKnowledge(knowledgeBaseId: knowledgeBaseId)) {
Image("icon-plus").resizable().scaledToFit().frame(width: 14, height: 14).foregroundColor(Color.zxPrimary)
}
}
ToolbarItem(placement: .topBarTrailing) {
Button { isSelectMode = true } label: {
Image(systemName: "checkmark.circle").font(.system(size: 16)).foregroundColor(Color.zxF05)
}
}
}
}
.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) }
}
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: 11)).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: 15)).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(systemName: "doc.badge.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(systemName: "photo.on.rectangle")
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: 13)).foregroundColor(Color.zxF0).lineLimit(1)
Spacer()
Text(f.size).font(.system(size: 11)).foregroundColor(Color.zxF04)
Button {
selectedFiles.removeAll { $0.id == f.id }
} label: {
Image(systemName: "xmark.circle.fill").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: 11))
Text("支持多选,每个文件生成一个知识点")
}
.font(.system(size: 11)).foregroundColor(Color.zxF04)
}
if isUploading {
HStack(spacing: 8) {
ProgressView()
Text("上传中 \(currentUploadIndex)/\(selectedFiles.count)...").font(.system(size: 13)).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.fill" }
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"
}
}
struct KnowledgeDetailPage: View {
let item: KnowledgeItem
var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
HStack { Spacer()
NavigationLink(value: Route.editKnowledge(item: item)) {
Image(systemName: "pencil").font(.system(size: 16)).foregroundColor(Color.zxF05)
.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))
}
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 8)
ScrollView { VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
HStack {
if let itemType = item.itemType { ZXChip(text: itemType, color: Color.zxPurple) }
if let sourceType = item.sourceType { ZXChip(text: sourceType, color: Color.zxAccent) }
}
Text(item.title).font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxF0)
if let content = item.content { Text(content).font(.system(size: 14)).foregroundColor(Color.zxF007).lineSpacing(6) }
}.padding(20).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20)).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
HStack(spacing: 12) {
NavigationLink(value: Route.studyHome) {
Label("开始复习", systemImage: "arrow.triangle.2.circlepath").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 44).background(ZXGradient.brandPurple).clipShape(RoundedRectangle(cornerRadius: 14))
}
NavigationLink(value: Route.aiChat) {
Label("费曼解释", systemImage: "mic.fill").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF05).frame(maxWidth: .infinity).frame(height: 44).background(Color.zxFill005).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
}
}
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden) }
}.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar)}
}
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.fill").foregroundColor(.red); Text(error).font(.system(size: 13)).foregroundColor(.red) }
.padding(12)
}
if !statusMessage.isEmpty {
HStack(spacing: 8) { ProgressView(); Text(statusMessage).font(.system(size: 13)).foregroundColor(Color.zxF04) }
.padding(12)
}
Button { showFilePicker = true } label: {
ZXImportRow(icon: "doc.text.fill", 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: 15, 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: 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)
}
Button {
Task { await acceptCandidate(c) }
} label: {
Label("接受", systemImage: "checkmark").font(.system(size: 13, 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: 15)).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)
}
}