// // AssetsViewModels.swift // suixinkan // // Created by Codex on 2026/6/23. // import Foundation import Observation /// 云盘传输记录仓库,保存本次 App 会话内的上传和下载进度。 @MainActor @Observable final class CloudTransferStore { static let shared = CloudTransferStore() private(set) var records: [CloudTransferItem] = [] /// 新增一条传输记录并返回记录 ID。 @discardableResult func start(fileName: String, direction: CloudTransferDirection) -> UUID { let id = UUID() records.insert(CloudTransferItem(id: id, fileName: fileName, direction: direction), at: 0) return id } /// 更新指定传输记录的进度。 func update(id: UUID, progress: Int, message: String = "") { guard let index = records.firstIndex(where: { $0.id == id }) else { return } records[index].progress = max(0, min(100, progress)) if !message.isEmpty { records[index].message = message } } /// 标记指定传输记录成功。 func succeed(id: UUID, message: String = "已完成") { guard let index = records.firstIndex(where: { $0.id == id }) else { return } records[index].progress = 100 records[index].status = .success records[index].message = message } /// 标记指定传输记录失败。 func fail(id: UUID, message: String) { guard let index = records.firstIndex(where: { $0.id == id }) else { return } records[index].status = .failed records[index].message = message } /// 清空当前会话传输记录。 func clear() { records = [] } } /// 云盘 ViewModel,负责文件列表、筛选、分页、文件操作和上传闭环。 @MainActor @Observable final class CloudStorageViewModel { var path: [CloudDriveFile] = [CloudDriveFile(id: 0, name: "云盘")] var files: [CloudDriveFile] = [] var total = 0 var page = 1 var searchText = "" var selectedFilter: CloudDriveFilter = .all var selectedSort: CloudDriveSort = .updatedDesc var isLoading = false var isLoadingMore = false var isMutating = false var errorMessage: String? private let pageSize = 20 /// 当前目录 ID。 var currentFolderId: Int { path.last?.id ?? 0 } /// 判断当前目录是否还有下一页。 var hasMore: Bool { files.count < total } /// 重新加载当前目录第一页。 func reload(api: any AssetsServing) async { isLoading = true errorMessage = nil defer { isLoading = false } do { let payload = try await requestFiles(api: api, page: 1) page = 1 files = payload.list total = payload.total } catch { files = [] total = 0 page = 1 errorMessage = error.localizedDescription } } /// 加载当前目录下一页。 func loadMore(api: any AssetsServing) async { guard hasMore, !isLoadingMore else { return } isLoadingMore = true defer { isLoadingMore = false } do { let nextPage = page + 1 let payload = try await requestFiles(api: api, page: nextPage) page = nextPage total = payload.total files.append(contentsOf: payload.list) } catch { errorMessage = error.localizedDescription } } /// 进入指定文件夹并刷新列表。 func enterFolder(_ folder: CloudDriveFile, api: any AssetsServing) async { guard folder.isFolder else { return } path.append(folder) await reload(api: api) } /// 回到指定面包屑目录并刷新列表。 func popToFolder(at index: Int, api: any AssetsServing) async { guard path.indices.contains(index) else { return } path = Array(path.prefix(index + 1)) await reload(api: api) } /// 创建文件夹并刷新当前目录。 func createFolder(name: String, api: any AssetsServing) async -> Bool { let folderName = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !folderName.isEmpty else { errorMessage = "请输入文件夹名称" return false } return await mutate(api: api) { try await api.cloudFolderCreate(CloudFolderCreateRequest(parentFolderId: currentFolderId, name: folderName)) } } /// 重命名文件或文件夹并刷新当前目录。 func rename(_ item: CloudDriveFile, newName: String, api: any AssetsServing) async -> Bool { let fileName = newName.trimmingCharacters(in: .whitespacesAndNewlines) guard !fileName.isEmpty else { errorMessage = "请输入名称" return false } return await mutate(api: api) { if item.isFolder { try await api.cloudFolderEdit(CloudFolderModifyRequest(id: item.id, name: fileName)) } else { try await api.cloudFileEdit(CloudFileModifyRequest(id: item.id, fileName: fileName)) } } } /// 删除文件或文件夹并刷新当前目录。 func delete(_ item: CloudDriveFile, api: any AssetsServing) async -> Bool { await mutate(api: api) { let action = CloudFileActionItem(id: item.id, type: item.type) try await api.cloudFileDelete(CloudFileDeleteRequest(list: [action])) } } /// 移动文件或文件夹并刷新当前目录。 func move(_ item: CloudDriveFile, targetFolderId: Int, api: any AssetsServing) async -> Bool { await mutate(api: api) { let action = CloudFileActionItem(id: item.id, type: item.type) try await api.cloudFileMove(CloudFileMoveRequest(targetFolderId: targetFolderId, list: [action])) } } /// 上传本地文件到 OSS 后写入云盘。 func upload( localFiles: [CloudLocalUploadFile], scenicId: Int?, api: any AssetsServing, uploadService: any OSSUploadServing, transferStore: CloudTransferStore ) async -> Bool { guard let scenicId else { errorMessage = "缺少当前景区,无法上传" return false } guard !localFiles.isEmpty else { return true } isMutating = true defer { isMutating = false } do { let permission = try await api.checkCloudUploadPermission() guard permission.canUpload else { let reason = permission.reason.trimmingCharacters(in: .whitespacesAndNewlines) errorMessage = reason.isEmpty ? "当前账号暂无上传权限" : reason return false } for file in localFiles { let transferId = transferStore.start(fileName: file.fileName, direction: .upload) do { let url = try await uploadService.uploadCloudFile( data: file.data, fileName: file.fileName, fileType: file.fileType, scenicId: scenicId ) { progress in Task { @MainActor in transferStore.update(id: transferId, progress: progress) } } try await api.cloudFileUpload( CloudFileUploadRequest(parentFolderId: currentFolderId, fileUrl: url, fileName: file.fileName) ) transferStore.succeed(id: transferId) } catch { transferStore.fail(id: transferId, message: error.localizedDescription) throw error } } await reload(api: api) return true } catch { errorMessage = error.localizedDescription return false } } /// 执行一次云盘变更操作并刷新当前目录。 private func mutate(api: any AssetsServing, operation: () async throws -> Void) async -> Bool { isMutating = true defer { isMutating = false } do { try await operation() await reload(api: api) return true } catch { errorMessage = error.localizedDescription return false } } /// 组装并发送云盘文件列表请求。 private func requestFiles(api: any AssetsServing, page: Int) async throws -> ListPayload { try await api.cloudFileList( parentFolderId: currentFolderId, name: searchText.trimmingCharacters(in: .whitespacesAndNewlines), type: selectedFilter.rawValue, orderBy: selectedSort.rawValue, page: page, pageSize: pageSize ) } } /// 素材库 ViewModel,负责素材列表、筛选、分页、详情和上下架。 @MainActor @Observable final class MediaLibraryViewModel { let kind: MediaLibraryKind var items: [MediaLibraryItem] = [] var selectedDetail: MediaLibraryDetail? var orderInfo: MediaLibraryOrderInfo? var total = 0 var page = 1 var keyword = "" var selectedAuditFilter: MediaLibraryAuditFilter = .all var isLoading = false var isLoadingMore = false var isLoadingDetail = false var errorMessage: String? private let pageSize = 10 /// 初始化素材库 ViewModel。 init(kind: MediaLibraryKind = .material) { self.kind = kind } /// 判断当前列表是否还有下一页。 var hasMore: Bool { items.count < total } /// 重新加载素材列表第一页。 func reload(api: any AssetsServing) async { isLoading = true errorMessage = nil defer { isLoading = false } do { let response = try await requestList(api: api, page: 1) page = 1 items = response.list total = response.total orderInfo = response.order } catch { items = [] total = 0 orderInfo = nil page = 1 errorMessage = error.localizedDescription } } /// 加载素材列表下一页。 func loadMore(api: any AssetsServing) async { guard hasMore, !isLoadingMore else { return } isLoadingMore = true defer { isLoadingMore = false } do { let nextPage = page + 1 let response = try await requestList(api: api, page: nextPage) page = nextPage total = response.total orderInfo = response.order ?? orderInfo items.append(contentsOf: response.list) } catch { errorMessage = error.localizedDescription } } /// 加载素材详情,失败时保留已有详情或摘要。 func loadDetail(id: Int, api: any AssetsServing) async { isLoadingDetail = true errorMessage = nil defer { isLoadingDetail = false } do { selectedDetail = try await api.mediaAlbumDetail(id: id) } catch { errorMessage = error.localizedDescription } } /// 更新素材上下架状态,审核未通过时禁止操作。 func toggleListing(_ item: MediaLibraryItem, api: any AssetsServing) async -> Bool { guard item.isApproved else { errorMessage = "审核通过后才能上下架" return false } return await operate(api: api) { let target = item.listing.toggled try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: item.id, listingStatus: target.rawValue)) } } /// 更新素材详情页上下架状态,审核未通过时禁止操作。 func toggleListing(detail: MediaLibraryDetail, api: any AssetsServing) async -> Bool { guard detail.isApproved else { errorMessage = "审核通过后才能上下架" return false } return await operate(api: api) { let target = detail.listing.toggled try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: detail.id, listingStatus: target.rawValue)) } } /// 删除素材并刷新列表。 func delete(id: Int, api: any AssetsServing) async -> Bool { await operate(api: api) { try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: id)) } } /// 执行素材变更操作并刷新列表。 private func operate(api: any AssetsServing, operation: () async throws -> Void) async -> Bool { do { try await operation() await reload(api: api) return true } catch { errorMessage = error.localizedDescription return false } } /// 组装并发送素材列表请求。 private func requestList(api: any AssetsServing, page: Int) async throws -> MediaLibraryListResponse { try await api.mediaAlbumList( kind: kind, keyword: keyword.trimmingCharacters(in: .whitespacesAndNewlines), status: selectedAuditFilter.rawValue >= 0 ? selectedAuditFilter.rawValue : nil, page: page, pageSize: pageSize ) } } /// 素材上传编辑 ViewModel,负责表单校验、OSS 上传和提交素材。 @MainActor @Observable final class MediaLibraryEditorViewModel { var name = "" var description = "" var selectedSpotId: Int? var tagsText = "" var coverFile: MediaLocalUploadFile? var mediaFiles: [MediaLocalUploadFile] = [] var isSubmitting = false var errorMessage: String? var didSubmitSuccessfully = false private let editingId: Int? /// 初始化上传或编辑素材表单。 init(detail: MediaLibraryDetail? = nil) { editingId = detail?.id name = detail?.name ?? "" description = detail?.description ?? "" } /// 判断当前是否处于编辑模式。 var isEditing: Bool { editingId != nil } /// 添加封面本地文件。 func setCover(_ file: MediaLocalUploadFile) { coverFile = file } /// 添加素材媒体文件。 func addMediaFiles(_ files: [MediaLocalUploadFile]) { mediaFiles.append(contentsOf: files) } /// 移除指定媒体文件。 func removeMediaFile(id: UUID) { mediaFiles.removeAll { $0.id == id } } /// 提交素材上传或编辑。 func submit( kind: MediaLibraryKind = .material, scenicId: Int?, api: any AssetsServing, uploadService: any OSSUploadServing ) async -> Bool { guard validate(scenicId: scenicId) else { return false } guard !isSubmitting else { return false } isSubmitting = true defer { isSubmitting = false } do { let actualScenicId = scenicId ?? 0 let coverUpload = try await uploadCover(scenicId: actualScenicId, uploadService: uploadService) let uploads = try await uploadMediaFiles(scenicId: actualScenicId, uploadService: uploadService) if let editingId { let request = MediaAlbumEditRequest( id: editingId, name: name.trimmingCharacters(in: .whitespacesAndNewlines), type: kind.rawValue, mediaType: resolvedMediaType(), mediaList: uploads, coverUrl: coverUpload.url, coverSize: coverUpload.size, scenicSpotId: selectedSpotId ?? 0, description: description, materialTag: normalizedTags() ) try await api.mediaAlbumEdit(request) } else { let request = MediaAlbumUploadRequest( name: name.trimmingCharacters(in: .whitespacesAndNewlines), type: kind.rawValue, mediaType: resolvedMediaType(), mediaList: uploads, coverUrl: coverUpload.url, coverSize: coverUpload.size, scenicSpotId: selectedSpotId ?? 0, description: description, materialTag: normalizedTags(), projectId: 0 ) try await api.mediaAlbumUpload(request) } didSubmitSuccessfully = true return true } catch { errorMessage = error.localizedDescription return false } } /// 校验素材表单。 private func validate(scenicId: Int?) -> Bool { let title = name.trimmingCharacters(in: .whitespacesAndNewlines) guard scenicId != nil else { errorMessage = "缺少当前景区,无法提交素材" return false } guard !title.isEmpty else { errorMessage = "请输入素材名称" return false } guard selectedSpotId != nil else { errorMessage = "请选择打卡点" return false } guard coverFile != nil else { errorMessage = "请选择封面" return false } guard !mediaFiles.isEmpty else { errorMessage = "请选择素材文件" return false } guard normalizedTags().count <= 120 else { errorMessage = "标签内容过长" return false } return true } /// 上传封面并返回 URL 和尺寸。 private func uploadCover( scenicId: Int, uploadService: any OSSUploadServing ) async throws -> (url: String, size: MediaAlbumFileSize) { guard let coverFile else { throw APIError.emptyData } let url = try await uploadService.uploadCloudFile( data: coverFile.data, fileName: coverFile.fileName, fileType: coverFile.fileType, scenicId: scenicId, onProgress: { _ in } ) return (url, MediaAlbumFileSize(width: coverFile.width, height: coverFile.height)) } /// 上传素材媒体文件并组装服务端请求项。 private func uploadMediaFiles( scenicId: Int, uploadService: any OSSUploadServing ) async throws -> [MediaAlbumUploadItem] { var result: [MediaAlbumUploadItem] = [] for file in mediaFiles { let url = try await uploadService.uploadCloudFile( data: file.data, fileName: file.fileName, fileType: file.fileType, scenicId: scenicId, onProgress: { _ in } ) result.append( MediaAlbumUploadItem( originalName: file.fileName, ossUrl: url, size: Int64(file.data.count), fileWidthSize: MediaAlbumFileSize(width: file.width, height: file.height) ) ) } return result } /// 根据已选文件推断素材媒体类型。 private func resolvedMediaType() -> Int { mediaFiles.contains { $0.fileType == 1 } ? 1 : 2 } /// 规范化用户输入的标签文本。 private func normalizedTags() -> String { tagsText .split { $0 == "," || $0 == "," || $0 == " " || $0 == "\n" } .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } .joined(separator: ",") } }