新增资产模块,包含云存储与媒体库流程
为云端与素材管理页面接入首页路由,将共享云存储模型迁入 Assets 模块,并补充 API/ViewModel 测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
629
suixinkan/Features/Assets/Views/CloudStorageViews.swift
Normal file
629
suixinkan/Features/Assets/Views/CloudStorageViews.swift
Normal file
@ -0,0 +1,629 @@
|
||||
//
|
||||
// CloudStorageViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import AVKit
|
||||
import Foundation
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
/// 相册云盘页面,展示文件夹浏览、筛选、上传、预览和文件操作。
|
||||
struct CloudStorageView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AssetsAPI.self) private var assetsAPI
|
||||
@Environment(OSSUploadService.self) private var uploadService
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = CloudStorageViewModel()
|
||||
@State private var transferStore = CloudTransferStore.shared
|
||||
@State private var selectedItems: [PhotosPickerItem] = []
|
||||
@State private var actionItem: CloudDriveFile?
|
||||
@State private var previewItem: CloudDriveFile?
|
||||
@State private var isCreatingFolder = false
|
||||
@State private var folderName = ""
|
||||
@State private var isRenaming = false
|
||||
@State private var renamingItem: CloudDriveFile?
|
||||
@State private var renameText = ""
|
||||
@State private var movingItem: CloudDriveFile?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: AppMetrics.Spacing.medium) {
|
||||
toolbarSection
|
||||
breadcrumbSection
|
||||
filterSection
|
||||
contentSection
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("相册云盘")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button {
|
||||
router.navigate(to: .home(.cloudStorageTransit))
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.arrow.down")
|
||||
}
|
||||
.accessibilityLabel("传输记录")
|
||||
|
||||
Button {
|
||||
folderName = ""
|
||||
isCreatingFolder = true
|
||||
} label: {
|
||||
Image(systemName: "folder.badge.plus")
|
||||
}
|
||||
.accessibilityLabel("新建文件夹")
|
||||
}
|
||||
}
|
||||
.refreshable { await reload(showLoading: false) }
|
||||
.task {
|
||||
guard viewModel.files.isEmpty else { return }
|
||||
await reload(showLoading: true)
|
||||
}
|
||||
.onChange(of: selectedItems) { _, items in
|
||||
Task { await upload(items) }
|
||||
}
|
||||
.confirmationDialog("文件操作", isPresented: actionDialogBinding, presenting: actionItem) { item in
|
||||
Button("预览") { previewItem = item }
|
||||
Button("下载") { Task { await download(item) } }
|
||||
Button("重命名") {
|
||||
renamingItem = item
|
||||
renameText = item.name
|
||||
isRenaming = true
|
||||
}
|
||||
Button("移动") { movingItem = item }
|
||||
Button("删除", role: .destructive) { Task { await delete(item) } }
|
||||
}
|
||||
.alert("新建文件夹", isPresented: $isCreatingFolder) {
|
||||
TextField("文件夹名称", text: $folderName)
|
||||
Button("取消", role: .cancel) {}
|
||||
Button("创建") { Task { await createFolder() } }
|
||||
}
|
||||
.alert("重命名", isPresented: $isRenaming) {
|
||||
TextField("名称", text: $renameText)
|
||||
Button("取消", role: .cancel) {}
|
||||
Button("保存") { Task { await renameSelectedItem() } }
|
||||
}
|
||||
.sheet(item: $movingItem) { item in
|
||||
CloudMoveFolderSheet(item: item, folders: moveTargets) { folderId in
|
||||
Task { await move(item, targetFolderId: folderId) }
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
.sheet(item: $previewItem) { item in
|
||||
CloudFilePreviewView(item: item)
|
||||
}
|
||||
}
|
||||
|
||||
/// 顶部工具区域,包含上传入口和当前统计。
|
||||
private var toolbarSection: some View {
|
||||
HStack(spacing: AppMetrics.Spacing.medium) {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
||||
Text("当前目录")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Text(viewModel.path.last?.name ?? "云盘")
|
||||
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
PhotosPicker(selection: $selectedItems, maxSelectionCount: 20, matching: .any(of: [.images, .videos])) {
|
||||
Label("上传", systemImage: "square.and.arrow.up")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
/// 面包屑区域,展示当前云盘路径。
|
||||
private var breadcrumbSection: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
ForEach(Array(viewModel.path.enumerated()), id: \.element.id) { index, folder in
|
||||
Button {
|
||||
Task { await viewModel.popToFolder(at: index, api: assetsAPI) }
|
||||
} label: {
|
||||
Text(folder.name)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(index == viewModel.path.count - 1 ? AppDesign.primary : AppDesign.textSecondary)
|
||||
|
||||
if index < viewModel.path.count - 1 {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.placeholder)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, AppMetrics.Spacing.xSmall)
|
||||
}
|
||||
}
|
||||
|
||||
/// 筛选区域,包含搜索、类型和排序。
|
||||
private var filterSection: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(AppDesign.placeholder)
|
||||
TextField("搜索文件或文件夹", text: $viewModel.searchText)
|
||||
.submitLabel(.search)
|
||||
.onSubmit { Task { await reload(showLoading: true) } }
|
||||
Button("搜索") {
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||
|
||||
Picker("类型", selection: $viewModel.selectedFilter) {
|
||||
ForEach(CloudDriveFilter.allCases) { filter in
|
||||
Text(filter.title).tag(filter)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: viewModel.selectedFilter) { _, _ in
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
|
||||
Picker("排序", selection: $viewModel.selectedSort) {
|
||||
ForEach(CloudDriveSort.allCases) { sort in
|
||||
Text(sort.title).tag(sort)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: viewModel.selectedSort) { _, _ in
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 列表内容区域,展示空状态、文件行和加载更多。
|
||||
@ViewBuilder
|
||||
private var contentSection: some View {
|
||||
if viewModel.files.isEmpty {
|
||||
AssetsEmptyState(title: "暂无文件", message: viewModel.errorMessage ?? "当前目录还没有云盘文件。")
|
||||
} else {
|
||||
ForEach(viewModel.files) { item in
|
||||
CloudDriveFileRow(item: item) {
|
||||
if item.isFolder {
|
||||
Task { await viewModel.enterFolder(item, api: assetsAPI) }
|
||||
} else {
|
||||
previewItem = item
|
||||
}
|
||||
} moreAction: {
|
||||
actionItem = item
|
||||
}
|
||||
.onAppear {
|
||||
guard item.id == viewModel.files.last?.id else { return }
|
||||
Task { await viewModel.loadMore(api: assetsAPI) }
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.isLoadingMore {
|
||||
ProgressView()
|
||||
.padding(.vertical, AppMetrics.Spacing.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 操作弹窗的绑定,关闭时同步清空当前文件。
|
||||
private var actionDialogBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { actionItem != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented { actionItem = nil }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// 可移动到的目标文件夹,当前版本提供根目录和当前目录可见文件夹。
|
||||
private var moveTargets: [CloudDriveFile] {
|
||||
[CloudDriveFile(id: 0, name: "云盘")] + viewModel.files.filter(\.isFolder)
|
||||
}
|
||||
|
||||
/// 重新加载当前目录。
|
||||
private func reload(showLoading: Bool) async {
|
||||
await globalLoading.withOptionalLoading(showLoading) {
|
||||
await viewModel.reload(api: assetsAPI)
|
||||
}
|
||||
if let message = viewModel.errorMessage {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建文件夹。
|
||||
private func createFolder() async {
|
||||
let success = await globalLoading.withLoading {
|
||||
await viewModel.createFolder(name: folderName, api: assetsAPI)
|
||||
}
|
||||
toastCenter.show(success ? "文件夹已创建" : (viewModel.errorMessage ?? "创建失败"))
|
||||
}
|
||||
|
||||
/// 重命名当前选中的文件或文件夹。
|
||||
private func renameSelectedItem() async {
|
||||
guard let item = renamingItem else { return }
|
||||
let success = await globalLoading.withLoading {
|
||||
await viewModel.rename(item, newName: renameText, api: assetsAPI)
|
||||
}
|
||||
toastCenter.show(success ? "已重命名" : (viewModel.errorMessage ?? "重命名失败"))
|
||||
renamingItem = nil
|
||||
}
|
||||
|
||||
/// 删除文件或文件夹。
|
||||
private func delete(_ item: CloudDriveFile) async {
|
||||
let success = await globalLoading.withLoading {
|
||||
await viewModel.delete(item, api: assetsAPI)
|
||||
}
|
||||
toastCenter.show(success ? "已删除" : (viewModel.errorMessage ?? "删除失败"))
|
||||
}
|
||||
|
||||
/// 移动文件或文件夹。
|
||||
private func move(_ item: CloudDriveFile, targetFolderId: Int) async {
|
||||
movingItem = nil
|
||||
let success = await globalLoading.withLoading {
|
||||
await viewModel.move(item, targetFolderId: targetFolderId, api: assetsAPI)
|
||||
}
|
||||
toastCenter.show(success ? "已移动" : (viewModel.errorMessage ?? "移动失败"))
|
||||
}
|
||||
|
||||
/// 读取 PhotosPicker 结果并上传到云盘。
|
||||
private func upload(_ items: [PhotosPickerItem]) async {
|
||||
guard !items.isEmpty else { return }
|
||||
defer { selectedItems = [] }
|
||||
let localFiles = await AssetPickerLoader.loadCloudFiles(from: items)
|
||||
let success = await globalLoading.withLoading {
|
||||
await viewModel.upload(
|
||||
localFiles: localFiles,
|
||||
scenicId: accountContext.currentScenic?.id,
|
||||
api: assetsAPI,
|
||||
uploadService: uploadService,
|
||||
transferStore: transferStore
|
||||
)
|
||||
}
|
||||
toastCenter.show(success ? "上传完成" : (viewModel.errorMessage ?? "上传失败"))
|
||||
}
|
||||
|
||||
/// 下载云盘文件到 App 沙盒 Documents/CloudDownloads。
|
||||
private func download(_ item: CloudDriveFile) async {
|
||||
guard let url = URL(string: item.fileUrl), !item.isFolder else {
|
||||
toastCenter.show("文件地址无效")
|
||||
return
|
||||
}
|
||||
let transferId = transferStore.start(fileName: item.name, direction: .download)
|
||||
do {
|
||||
let (tempURL, _) = try await URLSession.shared.download(from: url)
|
||||
let fileManager = FileManager.default
|
||||
let documents = try fileManager.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let folder = documents.appendingPathComponent("CloudDownloads", isDirectory: true)
|
||||
try fileManager.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
let destination = folder.appendingPathComponent(uniqueFileName(item.name, in: folder))
|
||||
if fileManager.fileExists(atPath: destination.path) {
|
||||
try fileManager.removeItem(at: destination)
|
||||
}
|
||||
try fileManager.moveItem(at: tempURL, to: destination)
|
||||
transferStore.succeed(id: transferId, message: destination.lastPathComponent)
|
||||
toastCenter.show("已下载到 CloudDownloads")
|
||||
} catch {
|
||||
transferStore.fail(id: transferId, message: error.localizedDescription)
|
||||
toastCenter.show("下载失败")
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成不覆盖已有文件的下载文件名。
|
||||
private func uniqueFileName(_ name: String, in folder: URL) -> String {
|
||||
let fileManager = FileManager.default
|
||||
let base = URL(fileURLWithPath: name).deletingPathExtension().lastPathComponent.assetsNonEmpty ?? "download"
|
||||
let ext = URL(fileURLWithPath: name).pathExtension
|
||||
var candidate = name
|
||||
var index = 1
|
||||
while fileManager.fileExists(atPath: folder.appendingPathComponent(candidate).path) {
|
||||
candidate = ext.isEmpty ? "\(base)-\(index)" : "\(base)-\(index).\(ext)"
|
||||
index += 1
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
/// 云盘文件行,展示图标、名称、大小和操作按钮。
|
||||
private struct CloudDriveFileRow: View {
|
||||
let item: CloudDriveFile
|
||||
let openAction: () -> Void
|
||||
let moreAction: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: AppMetrics.Spacing.medium) {
|
||||
Button(action: openAction) {
|
||||
thumbnail
|
||||
.frame(width: 54, height: 54)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button(action: openAction) {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
||||
Text(item.name)
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineLimit(2)
|
||||
Text(item.isFolder ? "\(item.childNum) 项" : "\(item.fileSize.formattedFileSize) · \(item.createdAt)")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button(action: moreAction) {
|
||||
Image(systemName: "ellipsis")
|
||||
.frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
/// 云盘文件缩略图。
|
||||
@ViewBuilder
|
||||
private var thumbnail: some View {
|
||||
if item.isFolder {
|
||||
Image(systemName: "folder.fill")
|
||||
.font(.system(size: 30, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.warning)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(hex: 0xFFF7ED))
|
||||
} else {
|
||||
RemoteImage(urlString: item.previewURLString, contentMode: .fill) {
|
||||
Image(systemName: item.isVideo ? "play.rectangle.fill" : "photo")
|
||||
.font(.system(size: 26, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(AppDesign.primarySoft)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 云盘移动目标选择弹窗。
|
||||
private struct CloudMoveFolderSheet: View {
|
||||
let item: CloudDriveFile
|
||||
let folders: [CloudDriveFile]
|
||||
let selectAction: (Int) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(folders.filter { $0.id != item.id }) { folder in
|
||||
Button {
|
||||
selectAction(folder.id)
|
||||
dismiss()
|
||||
} label: {
|
||||
Label(folder.name, systemImage: folder.id == 0 ? "externaldrive" : "folder")
|
||||
}
|
||||
}
|
||||
.navigationTitle("移动到")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 云盘文件预览页面,支持图片和视频的安全预览。
|
||||
private struct CloudFilePreviewView: View {
|
||||
let item: CloudDriveFile
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if item.isVideo, let url = URL(string: item.fileUrl) {
|
||||
VideoPlayer(player: AVPlayer(url: url))
|
||||
} else {
|
||||
RemoteImage(urlString: item.previewURLString, contentMode: .fit) {
|
||||
AssetsEmptyState(title: "无法预览", message: item.name)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black.opacity(item.isVideo ? 1 : 0.04))
|
||||
.navigationTitle(item.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("关闭") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 云盘传输记录页面,展示本次会话内上传和下载记录。
|
||||
struct CloudStorageTransitView: View {
|
||||
@State private var transferStore = CloudTransferStore.shared
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if transferStore.records.isEmpty {
|
||||
AssetsEmptyState(title: "暂无传输记录", message: "上传和下载记录只保留在本次 App 会话内。")
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
} else {
|
||||
ForEach(transferStore.records) { record in
|
||||
CloudTransferRow(record: record)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("传输记录")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("清空") { transferStore.clear() }
|
||||
.disabled(transferStore.records.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 云盘传输记录行。
|
||||
private struct CloudTransferRow: View {
|
||||
let record: CloudTransferItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
HStack {
|
||||
Image(systemName: record.direction == .upload ? "arrow.up.circle.fill" : "arrow.down.circle.fill")
|
||||
.foregroundStyle(record.status == .failed ? Color.red : AppDesign.primary)
|
||||
Text(record.fileName)
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text(record.status.title)
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(record.status.color)
|
||||
}
|
||||
ProgressView(value: Double(record.progress), total: 100)
|
||||
if !record.message.isEmpty {
|
||||
Text(record.message)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, AppMetrics.Spacing.xSmall)
|
||||
}
|
||||
}
|
||||
|
||||
/// 资产模块空状态视图。
|
||||
struct AssetsEmptyState: View {
|
||||
let title: String
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
Image(systemName: "tray")
|
||||
.font(.system(size: 34, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.placeholder)
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Text(message)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(AppMetrics.Spacing.xLarge)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
}
|
||||
|
||||
/// PhotosPicker 数据加载器,把系统选择结果转换为模块内本地文件实体。
|
||||
enum AssetPickerLoader {
|
||||
/// 加载云盘本地文件。
|
||||
@MainActor
|
||||
static func loadCloudFiles(from items: [PhotosPickerItem]) async -> [CloudLocalUploadFile] {
|
||||
var result: [CloudLocalUploadFile] = []
|
||||
for item in items {
|
||||
guard let data = try? await item.loadTransferable(type: Data.self) else { continue }
|
||||
let isVideo = item.supportedContentTypes.contains { $0.conforms(to: .movie) }
|
||||
let ext = isVideo ? "mp4" : "jpg"
|
||||
let fileName = "cloud_\(UUID().uuidString).\(ext)"
|
||||
result.append(CloudLocalUploadFile(data: data, fileName: fileName, fileType: isVideo ? 1 : 2))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// 加载素材本地文件。
|
||||
@MainActor
|
||||
static func loadMediaFiles(from items: [PhotosPickerItem]) async -> [MediaLocalUploadFile] {
|
||||
var result: [MediaLocalUploadFile] = []
|
||||
for item in items {
|
||||
guard let data = try? await item.loadTransferable(type: Data.self) else { continue }
|
||||
let isVideo = item.supportedContentTypes.contains { $0.conforms(to: .movie) }
|
||||
let ext = isVideo ? "mp4" : "jpg"
|
||||
let image = isVideo ? nil : UIImage(data: data)
|
||||
let fileName = "media_\(UUID().uuidString).\(ext)"
|
||||
result.append(
|
||||
MediaLocalUploadFile(
|
||||
data: data,
|
||||
fileName: fileName,
|
||||
fileType: isVideo ? 1 : 2,
|
||||
width: Int(image?.size.width ?? 0),
|
||||
height: Int(image?.size.height ?? 0)
|
||||
)
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private extension CloudTransferStatus {
|
||||
/// 返回传输状态展示文案。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .running:
|
||||
"进行中"
|
||||
case .success:
|
||||
"完成"
|
||||
case .failed:
|
||||
"失败"
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回传输状态展示颜色。
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .running:
|
||||
AppDesign.primary
|
||||
case .success:
|
||||
AppDesign.success
|
||||
case .failed:
|
||||
.red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Int64 {
|
||||
/// 返回文件大小展示文案。
|
||||
var formattedFileSize: String {
|
||||
let value = Double(self)
|
||||
if value >= 1_073_741_824 {
|
||||
return String(format: "%.1fGB", value / 1_073_741_824)
|
||||
}
|
||||
if value >= 1_048_576 {
|
||||
return String(format: "%.1fMB", value / 1_048_576)
|
||||
}
|
||||
if value >= 1024 {
|
||||
return String(format: "%.1fKB", value / 1024)
|
||||
}
|
||||
return "\(self)B"
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
/// 返回去除空白后的非空字符串,供资产页面避免依赖其他文件私有扩展。
|
||||
var assetsNonEmpty: String? {
|
||||
let value = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
610
suixinkan/Features/Assets/Views/MediaLibraryViews.swift
Normal file
610
suixinkan/Features/Assets/Views/MediaLibraryViews.swift
Normal file
@ -0,0 +1,610 @@
|
||||
//
|
||||
// MediaLibraryViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
|
||||
/// 素材管理页面,展示素材列表、筛选、统计和上下架入口。
|
||||
struct MediaLibraryView: View {
|
||||
@Environment(AssetsAPI.self) private var assetsAPI
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = MediaLibraryViewModel(kind: .material)
|
||||
@State private var selectedItem: MediaLibraryItem?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: AppMetrics.Spacing.medium) {
|
||||
filterSection
|
||||
statsSection
|
||||
contentSection
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("素材管理")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
router.navigate(to: .home(.materialUpload))
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("上传素材")
|
||||
}
|
||||
}
|
||||
.refreshable { await reload(showLoading: false) }
|
||||
.task {
|
||||
guard viewModel.items.isEmpty else { return }
|
||||
await reload(showLoading: true)
|
||||
}
|
||||
.navigationDestination(item: $selectedItem) { item in
|
||||
MediaLibraryDetailView(item: item, listViewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
/// 筛选区域,包含关键词和审核状态。
|
||||
private var filterSection: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(AppDesign.placeholder)
|
||||
TextField("搜索素材名称", text: $viewModel.keyword)
|
||||
.submitLabel(.search)
|
||||
.onSubmit { Task { await reload(showLoading: true) } }
|
||||
Button("搜索") {
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||
|
||||
Picker("审核状态", selection: $viewModel.selectedAuditFilter) {
|
||||
ForEach(MediaLibraryAuditFilter.allCases) { filter in
|
||||
Text(filter.title).tag(filter)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: viewModel.selectedAuditFilter) { _, _ in
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 统计区域,展示素材订单概览。
|
||||
@ViewBuilder
|
||||
private var statsSection: some View {
|
||||
if let info = viewModel.orderInfo {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
MediaStatCard(title: "订单数", value: "\(info.totalNum)")
|
||||
MediaStatCard(title: "均价", value: String(format: "%.2f", info.avgOrderAmount))
|
||||
MediaStatCard(title: "退款", value: String(format: "%.2f", info.refundTotal))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 列表内容区域。
|
||||
@ViewBuilder
|
||||
private var contentSection: some View {
|
||||
if viewModel.items.isEmpty {
|
||||
AssetsEmptyState(title: "暂无素材", message: viewModel.errorMessage ?? "当前筛选条件下没有素材。")
|
||||
} else {
|
||||
ForEach(viewModel.items) { item in
|
||||
MediaLibraryCard(item: item) {
|
||||
selectedItem = item
|
||||
} toggleAction: {
|
||||
Task { await toggleListing(item) }
|
||||
}
|
||||
.onAppear {
|
||||
guard item.id == viewModel.items.last?.id else { return }
|
||||
Task { await viewModel.loadMore(api: assetsAPI) }
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.isLoadingMore {
|
||||
ProgressView()
|
||||
.padding(.vertical, AppMetrics.Spacing.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 重新加载素材列表。
|
||||
private func reload(showLoading: Bool) async {
|
||||
await globalLoading.withOptionalLoading(showLoading) {
|
||||
await viewModel.reload(api: assetsAPI)
|
||||
}
|
||||
if let message = viewModel.errorMessage {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换素材上下架状态。
|
||||
private func toggleListing(_ item: MediaLibraryItem) async {
|
||||
let success = await globalLoading.withLoading {
|
||||
await viewModel.toggleListing(item, api: assetsAPI)
|
||||
}
|
||||
toastCenter.show(success ? "状态已更新" : (viewModel.errorMessage ?? "操作失败"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材统计卡片。
|
||||
private struct MediaStatCard: View {
|
||||
let title: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Text(value)
|
||||
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材列表卡片。
|
||||
private struct MediaLibraryCard: View {
|
||||
let item: MediaLibraryItem
|
||||
let openAction: () -> Void
|
||||
let toggleAction: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: openAction) {
|
||||
HStack(spacing: AppMetrics.Spacing.medium) {
|
||||
RemoteImage(urlString: item.coverUrl, contentMode: .fill) {
|
||||
Image(systemName: "photo")
|
||||
.font(.system(size: 28, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(AppDesign.primarySoft)
|
||||
}
|
||||
.frame(width: 74, height: 74)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
||||
Text(item.name)
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineLimit(2)
|
||||
Text(item.scenicSpotName.assetsNonEmpty ?? item.projectName.assetsNonEmpty ?? "未关联打卡点")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
MediaStatusBadge(text: item.status.auditTitle, color: item.status.auditColor)
|
||||
MediaStatusBadge(text: item.listing.title, color: item.listing == .online ? AppDesign.success : AppDesign.placeholder)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: toggleAction) {
|
||||
Image(systemName: item.listing == .online ? "arrow.down.circle" : "arrow.up.circle")
|
||||
.frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(item.isApproved ? AppDesign.primary : AppDesign.placeholder)
|
||||
.disabled(!item.isApproved)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材状态标签。
|
||||
private struct MediaStatusBadge: View {
|
||||
let text: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, AppMetrics.Spacing.xSmall)
|
||||
.padding(.vertical, AppMetrics.Spacing.xxSmall)
|
||||
.background(color.opacity(0.1), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材详情页面,展示基础信息、统计、媒体文件和操作按钮。
|
||||
struct MediaLibraryDetailView: View {
|
||||
let item: MediaLibraryItem
|
||||
let listViewModel: MediaLibraryViewModel
|
||||
|
||||
@Environment(AssetsAPI.self) private var assetsAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var isEditing = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
headerSection
|
||||
mediaSection
|
||||
actionSection
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("素材详情")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await globalLoading.withOptionalLoading(listViewModel.selectedDetail?.id != item.id) {
|
||||
await listViewModel.loadDetail(id: item.id, api: assetsAPI)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isEditing) {
|
||||
MediaLibraryUploadView(editingDetail: listViewModel.selectedDetail)
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材详情头部信息。
|
||||
private var headerSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
|
||||
RemoteImage(urlString: currentCoverURL, contentMode: .fill) {
|
||||
Image(systemName: "photo")
|
||||
.font(.system(size: 40, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(AppDesign.primarySoft)
|
||||
}
|
||||
.frame(height: 180)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text(currentName)
|
||||
.font(.system(size: AppMetrics.FontSize.title2, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Text(listViewModel.selectedDetail?.description.assetsNonEmpty ?? "暂无描述")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
HStack {
|
||||
MediaStatusBadge(text: currentStatus.auditTitle, color: currentStatus.auditColor)
|
||||
MediaStatusBadge(text: currentListing.title, color: currentListing == .online ? AppDesign.success : AppDesign.placeholder)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
/// 素材媒体文件区域。
|
||||
@ViewBuilder
|
||||
private var mediaSection: some View {
|
||||
let media = listViewModel.selectedDetail?.mediaList ?? []
|
||||
if media.isEmpty {
|
||||
AssetsEmptyState(title: "暂无媒体文件", message: "详情接口暂未返回素材文件。")
|
||||
} else {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 110), spacing: AppMetrics.Spacing.small)], spacing: AppMetrics.Spacing.small) {
|
||||
ForEach(media) { file in
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
||||
RemoteImage(urlString: file.thumbnailUrl.assetsNonEmpty ?? file.showUrl.assetsNonEmpty ?? file.ossUrl, contentMode: .fill) {
|
||||
Image(systemName: file.isVideo ? "play.rectangle.fill" : "photo")
|
||||
.font(.system(size: 28, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(AppDesign.primarySoft)
|
||||
}
|
||||
.frame(height: 96)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||
Text(file.originalName)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.xSmall)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 操作按钮区域。
|
||||
private var actionSection: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
Button("编辑素材") {
|
||||
isEditing = true
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: AppMetrics.ControlSize.primaryButtonHeight)
|
||||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
Button(currentListing == .online ? "下架" : "上架") {
|
||||
Task { await toggleListing() }
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: AppMetrics.ControlSize.sheetButtonHeight)
|
||||
.background(Color.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||||
|
||||
Button("删除", role: .destructive) {
|
||||
Task { await delete() }
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: AppMetrics.ControlSize.sheetButtonHeight)
|
||||
.background(Color.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||||
}
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
}
|
||||
|
||||
private var currentName: String { listViewModel.selectedDetail?.name ?? item.name }
|
||||
private var currentCoverURL: String { listViewModel.selectedDetail?.coverUrl ?? item.coverUrl }
|
||||
private var currentStatus: Int { listViewModel.selectedDetail?.status ?? item.status }
|
||||
private var currentListing: MediaListingStatus { listViewModel.selectedDetail?.listing ?? item.listing }
|
||||
|
||||
/// 切换详情素材上下架状态。
|
||||
private func toggleListing() async {
|
||||
guard let detail = listViewModel.selectedDetail else { return }
|
||||
let success = await globalLoading.withLoading {
|
||||
await listViewModel.toggleListing(detail: detail, api: assetsAPI)
|
||||
}
|
||||
toastCenter.show(success ? "状态已更新" : (listViewModel.errorMessage ?? "操作失败"))
|
||||
}
|
||||
|
||||
/// 删除素材并返回列表。
|
||||
private func delete() async {
|
||||
let success = await globalLoading.withLoading {
|
||||
await listViewModel.delete(id: item.id, api: assetsAPI)
|
||||
}
|
||||
toastCenter.show(success ? "已删除" : (listViewModel.errorMessage ?? "删除失败"))
|
||||
if success { dismiss() }
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材上传或编辑页面,负责选择封面、媒体文件和打卡点后提交。
|
||||
struct MediaLibraryUploadView: View {
|
||||
let editingDetail: MediaLibraryDetail?
|
||||
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(ScenicSpotContext.self) private var scenicSpotContext
|
||||
@Environment(AssetsAPI.self) private var assetsAPI
|
||||
@Environment(OSSUploadService.self) private var uploadService
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel: MediaLibraryEditorViewModel
|
||||
@State private var coverSelection: PhotosPickerItem?
|
||||
@State private var mediaSelection: [PhotosPickerItem] = []
|
||||
|
||||
/// 初始化素材上传或编辑页面。
|
||||
init(editingDetail: MediaLibraryDetail? = nil) {
|
||||
self.editingDetail = editingDetail
|
||||
_viewModel = State(initialValue: MediaLibraryEditorViewModel(detail: editingDetail))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
formSection
|
||||
spotSection
|
||||
coverSection
|
||||
mediaSection
|
||||
submitButton
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle(viewModel.isEditing ? "编辑素材" : "上传素材")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onChange(of: coverSelection) { _, item in
|
||||
Task { await loadCover(item) }
|
||||
}
|
||||
.onChange(of: mediaSelection) { _, items in
|
||||
Task { await loadMedia(items) }
|
||||
}
|
||||
}
|
||||
|
||||
/// 表单基础信息区域。
|
||||
private var formSection: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
TextField("素材名称", text: $viewModel.name)
|
||||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||
|
||||
TextField("标签,用逗号分隔", text: $viewModel.tagsText)
|
||||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||
|
||||
TextField("描述", text: $viewModel.description, axis: .vertical)
|
||||
.lineLimit(3...5)
|
||||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: 88)
|
||||
}
|
||||
}
|
||||
|
||||
/// 打卡点选择区域。
|
||||
private var spotSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
Text("打卡点")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
if scenicSpotContext.spots.isEmpty {
|
||||
AssetsEmptyState(title: "暂无打卡点", message: "当前景区暂未加载到可选打卡点。")
|
||||
} else {
|
||||
Picker("打卡点", selection: $viewModel.selectedSpotId) {
|
||||
Text("请选择").tag(nil as Int?)
|
||||
ForEach(scenicSpotContext.spots) { spot in
|
||||
Text(spot.name).tag(Optional(spot.id))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 封面选择区域。
|
||||
private var coverSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
Text("封面")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
PhotosPicker(selection: $coverSelection, matching: .images) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)
|
||||
.fill(Color.white)
|
||||
if let coverFile = viewModel.coverFile, let image = UIImage(data: coverFile.data) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else if let url = editingDetail?.coverUrl, !url.isEmpty {
|
||||
RemoteImage(urlString: url, contentMode: .fill) {
|
||||
Image(systemName: "photo")
|
||||
.font(.system(size: 34, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
} else {
|
||||
Label("选择封面", systemImage: "photo.badge.plus")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
}
|
||||
.frame(height: 170)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 媒体文件选择区域。
|
||||
private var mediaSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
HStack {
|
||||
Text("媒体文件")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
Spacer()
|
||||
PhotosPicker(selection: $mediaSelection, maxSelectionCount: 20, matching: .any(of: [.images, .videos])) {
|
||||
Label("添加", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.mediaFiles.isEmpty {
|
||||
AssetsEmptyState(title: "未选择素材文件", message: "可选择图片或视频,提交前会先上传到 OSS。")
|
||||
} else {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 96), spacing: AppMetrics.Spacing.small)], spacing: AppMetrics.Spacing.small) {
|
||||
ForEach(viewModel.mediaFiles) { file in
|
||||
ZStack(alignment: .topTrailing) {
|
||||
MediaLocalPreview(file: file)
|
||||
Button {
|
||||
viewModel.removeMediaFile(id: file.id)
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.white, Color.black.opacity(0.5))
|
||||
}
|
||||
.padding(AppMetrics.Spacing.xxSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交按钮。
|
||||
private var submitButton: some View {
|
||||
Button {
|
||||
Task { await submit() }
|
||||
} label: {
|
||||
Text(viewModel.isSubmitting ? "提交中..." : "提交")
|
||||
.frame(maxWidth: .infinity, minHeight: AppMetrics.ControlSize.primaryButtonHeight)
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||||
.disabled(viewModel.isSubmitting)
|
||||
}
|
||||
|
||||
/// 加载封面选择结果。
|
||||
private func loadCover(_ item: PhotosPickerItem?) async {
|
||||
guard let item else { return }
|
||||
let files = await AssetPickerLoader.loadMediaFiles(from: [item])
|
||||
if let file = files.first {
|
||||
viewModel.setCover(file)
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载媒体选择结果。
|
||||
private func loadMedia(_ items: [PhotosPickerItem]) async {
|
||||
guard !items.isEmpty else { return }
|
||||
defer { mediaSelection = [] }
|
||||
let files = await AssetPickerLoader.loadMediaFiles(from: items)
|
||||
viewModel.addMediaFiles(files)
|
||||
}
|
||||
|
||||
/// 提交素材表单。
|
||||
private func submit() async {
|
||||
let success = await globalLoading.withLoading {
|
||||
await viewModel.submit(
|
||||
scenicId: accountContext.currentScenic?.id,
|
||||
api: assetsAPI,
|
||||
uploadService: uploadService
|
||||
)
|
||||
}
|
||||
toastCenter.show(success ? "素材已提交" : (viewModel.errorMessage ?? "提交失败"))
|
||||
if success { dismiss() }
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地素材文件预览卡。
|
||||
private struct MediaLocalPreview: View {
|
||||
let file: MediaLocalUploadFile
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if file.fileType == 2, let image = UIImage(data: file.data) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
Image(systemName: "play.rectangle.fill")
|
||||
.font(.system(size: 30, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(AppDesign.primarySoft)
|
||||
}
|
||||
}
|
||||
.frame(height: 96)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||
}
|
||||
}
|
||||
|
||||
private extension Int {
|
||||
/// 返回素材审核状态文案。
|
||||
var auditTitle: String {
|
||||
switch self {
|
||||
case 1:
|
||||
"已通过"
|
||||
case 2:
|
||||
"未通过"
|
||||
default:
|
||||
"待审核"
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回素材审核状态颜色。
|
||||
var auditColor: Color {
|
||||
switch self {
|
||||
case 1:
|
||||
AppDesign.success
|
||||
case 2:
|
||||
.red
|
||||
default:
|
||||
AppDesign.warning
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user