// // 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 { @EnvironmentObject private var accountContext: AccountContext @Environment(\.assetsAPI) private var assetsAPI @Environment(\.ossUploadService) private var uploadService @EnvironmentObject private var router: RouterPath @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading @StateObject 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 { 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 } }