Files
suixinkan_ios_new/suixinkan/Features/Assets/Views/CloudStorageViews.swift
汉秋 a48eb76ba4 Add Assets module with cloud storage and media library flows.
Wire home routing for cloud and material management pages, move shared cloud models into Assets, and add API/view model tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 16:10:20 +08:00

630 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

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

//
// 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
}
}