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>
This commit is contained in:
2026-06-23 16:10:20 +08:00
parent 3e898d93d0
commit a48eb76ba4
15 changed files with 3431 additions and 75 deletions

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