- 移除 AnimatedTabBarHide 环境值动画系统 - 所有子页面统一使用 .toolbar(.hidden, for: .tabBar) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
917 lines
51 KiB
Swift
917 lines
51 KiB
Swift
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)
|
||
}
|
||
}
|