// // AssetsViewModels.swift // suixinkan // // Created by Codex on 2026/6/23. // import Foundation /// 云盘传输记录仓库,保存本次 App 会话内的上传和下载进度。 @MainActor final class CloudTransferStore { var onChange: (() -> Void)? static let shared = CloudTransferStore() private(set) var records: [CloudTransferItem] = [] { didSet { onChange?() } } /// 新增一条传输记录并返回记录 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 final class CloudStorageViewModel { var onChange: (() -> Void)? var path: [CloudDriveFile] = [CloudDriveFile(id: 0, name: "云盘")] { didSet { onChange?() } } var files: [CloudDriveFile] = [] { didSet { onChange?() } } var total = 0 { didSet { onChange?() } } var page = 1 { didSet { onChange?() } } var searchText = "" { didSet { onChange?() } } var selectedFilter: CloudDriveFilter = .all { didSet { onChange?() } } var selectedSort: CloudDriveSort = .updatedDesc { didSet { onChange?() } } var isLoading = false { didSet { onChange?() } } var isLoadingMore = false { didSet { onChange?() } } var isMutating = false { didSet { onChange?() } } var errorMessage: String? { didSet { onChange?() } } 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 final class MediaLibraryViewModel { var onChange: (() -> Void)? let kind: MediaLibraryKind var items: [MediaLibraryItem] = [] { didSet { onChange?() } } var selectedDetail: MediaLibraryDetail? { didSet { onChange?() } } var orderInfo: MediaLibraryOrderInfo? { didSet { onChange?() } } var total = 0 { didSet { onChange?() } } var page = 1 { didSet { onChange?() } } var keyword = "" { didSet { onChange?() } } var selectedAuditFilter: MediaLibraryAuditFilter = .all { didSet { onChange?() } } var isLoading = false { didSet { onChange?() } } var isLoadingMore = false { didSet { onChange?() } } var isLoadingDetail = false { didSet { onChange?() } } var errorMessage: String? { didSet { onChange?() } } 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, kind: kind) } 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, type: kind.rawValue, 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, type: kind.rawValue, listingStatus: target.rawValue)) } } /// 删除素材或样片并刷新列表。 func delete(id: Int, api: any AssetsServing) async -> Bool { await operate(api: api) { try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: id, type: kind.rawValue)) } } /// 执行媒体库变更操作并刷新列表。 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 final class MediaLibraryEditorViewModel { var onChange: (() -> Void)? var name = "" { didSet { onChange?() } } var description = "" { didSet { onChange?() } } var selectedSpotId: Int? { didSet { onChange?() } } var selectedProjectId: Int? { didSet { onChange?() } } var projects: [PhotographerProjectItem] = [] { didSet { onChange?() } } var tagsText = "" { didSet { onChange?() } } var coverFile: MediaLocalUploadFile? { didSet { onChange?() } } var mediaFiles: [MediaLocalUploadFile] = [] { didSet { onChange?() } } var isSubmitting = false { didSet { onChange?() } } var isLoadingProjects = false { didSet { onChange?() } } var errorMessage: String? { didSet { onChange?() } } var didSubmitSuccessfully = false { didSet { onChange?() } } private let editingId: Int? /// 初始化上传或编辑素材表单。 init(detail: MediaLibraryDetail? = nil) { editingId = detail?.id name = detail?.name ?? "" description = detail?.description ?? "" } /// 判断当前是否处于编辑模式。 var isEditing: Bool { editingId != nil } /// 加载样片上传可关联的项目列表。 func loadProjects(scenicId: Int?, api: any AssetsServing) async { guard let scenicId else { projects = [] errorMessage = "缺少当前景区,无法加载项目" return } isLoadingProjects = true errorMessage = nil defer { isLoadingProjects = false } do { let payload = try await api.projectList(scenicId: scenicId, name: nil, page: 1, pageSize: 50) projects = payload.list if let selectedProjectId, !projects.contains(where: { $0.id == selectedProjectId }) { self.selectedProjectId = nil } } catch { projects = [] errorMessage = error.localizedDescription } } /// 添加封面本地文件。 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(kind: kind, 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: kind == .sample ? (selectedProjectId ?? 0) : 0 ) try await api.mediaAlbumUpload(request) } didSubmitSuccessfully = true return true } catch { errorMessage = error.localizedDescription return false } } /// 校验素材或样片表单。 private func validate(kind: MediaLibraryKind, scenicId: Int?) -> Bool { let title = name.trimmingCharacters(in: .whitespacesAndNewlines) guard scenicId != nil else { errorMessage = "缺少当前景区,无法提交\(kind == .sample ? "样片" : "素材")" return false } guard !title.isEmpty else { errorMessage = "请输入\(kind == .sample ? "样片" : "素材")名称" return false } guard selectedSpotId != nil else { errorMessage = "请选择打卡点" return false } guard kind != .sample || selectedProjectId != nil else { errorMessage = "请选择关联项目" return false } guard coverFile != nil else { errorMessage = "请选择封面" return false } guard !mediaFiles.isEmpty else { errorMessage = "请选择\(kind == .sample ? "样片" : "素材")文件" 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: ",") } } /// 相册列表 ViewModel,负责相册文件夹搜索、日期筛选、分页和新建相册。 @MainActor final class AlbumListViewModel { var onChange: (() -> Void)? var folders: [AlbumFolderItem] = [] { didSet { onChange?() } } var total = 0 { didSet { onChange?() } } var page = 1 { didSet { onChange?() } } var searchText = "" { didSet { onChange?() } } var startTime = "" { didSet { onChange?() } } var endTime = "" { didSet { onChange?() } } var isLoading = false { didSet { onChange?() } } var isLoadingMore = false { didSet { onChange?() } } var isMutating = false { didSet { onChange?() } } var errorMessage: String? { didSet { onChange?() } } private let pageSize = 10 /// 判断当前相册列表是否还有下一页。 var hasMore: Bool { folders.count < total } /// 重新加载相册第一页。 func reload(api: any AssetsServing, scenicId: Int?) async { guard let scenicId else { folders = [] total = 0 page = 1 errorMessage = "缺少当前景区,无法加载相册" return } isLoading = true errorMessage = nil defer { isLoading = false } do { let payload = try await requestFolders(api: api, scenicId: scenicId, page: 1) self.page = 1 folders = payload.list total = payload.total } catch { folders = [] total = 0 page = 1 errorMessage = error.localizedDescription } } /// 加载相册下一页。 func loadMore(api: any AssetsServing, scenicId: Int?) async { guard let scenicId, hasMore, !isLoadingMore else { return } isLoadingMore = true defer { isLoadingMore = false } do { let nextPage = page + 1 let payload = try await requestFolders(api: api, scenicId: scenicId, page: nextPage) page = nextPage total = payload.total folders.append(contentsOf: payload.list) } catch { errorMessage = error.localizedDescription } } /// 新建相册并刷新列表。 func createFolder(name: String, remark: String, scenicId: Int?, api: any AssetsServing) async -> Bool { let folderName = name.trimmingCharacters(in: .whitespacesAndNewlines) guard let scenicId else { errorMessage = "缺少当前景区,无法创建相册" return false } guard !folderName.isEmpty else { errorMessage = "请输入相册名称" return false } guard !isMutating else { return false } isMutating = true defer { isMutating = false } do { try await api.addAlbumFolder(scenicId: scenicId, name: folderName, remark: remark.trimmingCharacters(in: .whitespacesAndNewlines)) await reload(api: api, scenicId: scenicId) return true } catch { errorMessage = error.localizedDescription return false } } /// 组装相册列表请求参数。 private func requestFolders(api: any AssetsServing, scenicId: Int, page: Int) async throws -> ListPayload { try await api.albumFolderList( scenicId: scenicId, page: page, pageSize: pageSize, name: searchText.trimmingCharacters(in: .whitespacesAndNewlines), startTime: startTime.trimmingCharacters(in: .whitespacesAndNewlines).assetsNilIfEmpty, endTime: endTime.trimmingCharacters(in: .whitespacesAndNewlines).assetsNilIfEmpty ) } } /// 相册详情 ViewModel,负责相册信息、图片/视频列表和文件操作。 @MainActor final class AlbumDetailViewModel { var onChange: (() -> Void)? let folderId: Int var folder: AlbumFolderItem? { didSet { onChange?() } } var files: [AlbumFileItem] = [] { didSet { onChange?() } } var selectedTab: AlbumFileTab = .image { didSet { onChange?() } } var total = 0 { didSet { onChange?() } } var page = 1 { didSet { onChange?() } } var isLoading = false { didSet { onChange?() } } var isLoadingMore = false { didSet { onChange?() } } var isMutating = false { didSet { onChange?() } } var errorMessage: String? { didSet { onChange?() } } private let pageSize = 20 /// 初始化相册详情 ViewModel。 init(folderId: Int, summary: AlbumFolderItem? = nil) { self.folderId = folderId folder = summary } /// 判断当前文件列表是否还有下一页。 var hasMore: Bool { files.count < total } /// 重新加载相册信息和当前 Tab 文件列表。 func reload(api: any AssetsServing, scenicId: Int?) async { guard let scenicId else { files = [] total = 0 page = 1 errorMessage = "缺少当前景区,无法加载相册内容" return } isLoading = true errorMessage = nil defer { isLoading = false } do { folder = try await api.albumFolderInfo(id: folderId) let listPayload = try await api.albumFileList( scenicId: scenicId, folderId: folderId, fileType: selectedTab.rawValue, page: 1, pageSize: pageSize ) files = listPayload.list total = listPayload.total page = 1 } catch { page = 1 errorMessage = error.localizedDescription } } /// 切换图片或视频列表并重新加载第一页。 func selectTab(_ tab: AlbumFileTab, api: any AssetsServing, scenicId: Int?) async { guard selectedTab != tab else { return } selectedTab = tab files = [] total = 0 page = 1 await reload(api: api, scenicId: scenicId) } /// 加载当前 Tab 下一页文件。 func loadMore(api: any AssetsServing, scenicId: Int?) async { guard let scenicId, hasMore, !isLoadingMore else { return } isLoadingMore = true defer { isLoadingMore = false } do { let nextPage = page + 1 let payload = try await api.albumFileList( scenicId: scenicId, folderId: folderId, fileType: selectedTab.rawValue, page: nextPage, pageSize: pageSize ) page = nextPage total = payload.total files.append(contentsOf: payload.list) } catch { errorMessage = error.localizedDescription } } /// 删除相册文件并刷新当前列表。 func delete(file: AlbumFileItem, scenicId: Int?, api: any AssetsServing) async -> Bool { await mutate(api: api, scenicId: scenicId) { try await api.deleteAlbumFiles(folderId: folderId, idList: [file.id]) } } /// 将指定相册文件设为封面。 func setCover(file: AlbumFileItem, scenicId: Int?, api: any AssetsServing) async -> Bool { await mutate(api: api, scenicId: scenicId) { try await api.editAlbumFolder(folderId: folderId, coverFileId: file.id, name: nil, remark: nil) } } /// 更新相册名称。 func rename(name: String, scenicId: Int?, api: any AssetsServing) async -> Bool { let folderName = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !folderName.isEmpty else { errorMessage = "请输入相册名称" return false } return await mutate(api: api, scenicId: scenicId) { try await api.editAlbumFolder(folderId: folderId, coverFileId: nil, name: folderName, remark: nil) } } /// 更新相册备注。 func updateRemark(_ remark: String, scenicId: Int?, api: any AssetsServing) async -> Bool { await mutate(api: api, scenicId: scenicId) { try await api.editAlbumFolder(folderId: folderId, coverFileId: nil, name: nil, remark: remark.trimmingCharacters(in: .whitespacesAndNewlines)) } } /// 执行相册变更操作并刷新详情。 private func mutate(api: any AssetsServing, scenicId: Int?, operation: () async throws -> Void) async -> Bool { guard !isMutating else { return false } guard scenicId != nil else { errorMessage = "缺少当前景区,无法操作相册" return false } isMutating = true defer { isMutating = false } do { try await operation() await reload(api: api, scenicId: scenicId) return true } catch { errorMessage = error.localizedDescription return false } } } /// 相册预览上传 ViewModel,负责选择相册、本地文件上传和相册入库。 @MainActor final class AlbumTrailerViewModel { var onChange: (() -> Void)? var folders: [AlbumFolderItem] = [] { didSet { onChange?() } } var selectedFolderId: Int? { didSet { onChange?() } } var localFiles: [AlbumLocalUploadFile] = [] { didSet { onChange?() } } var uploadProgress = 0 { didSet { onChange?() } } var isLoadingFolders = false { didSet { onChange?() } } var isSubmitting = false { didSet { onChange?() } } var errorMessage: String? { didSet { onChange?() } } var didUploadSuccessfully = false { didSet { onChange?() } } /// 加载可上传的相册列表。 func loadFolders(api: any AssetsServing, scenicId: Int?) async { guard let scenicId else { folders = [] selectedFolderId = nil errorMessage = "缺少当前景区,无法加载相册" return } isLoadingFolders = true errorMessage = nil defer { isLoadingFolders = false } do { let payload = try await api.albumFolderList( scenicId: scenicId, page: 1, pageSize: 100, name: "", startTime: nil, endTime: nil ) folders = payload.list if selectedFolderId == nil { selectedFolderId = folders.first?.id } } catch { folders = [] selectedFolderId = nil errorMessage = error.localizedDescription } } /// 添加本地待上传文件。 func addLocalFiles(_ files: [AlbumLocalUploadFile]) { localFiles.append(contentsOf: files) } /// 移除一个本地待上传文件。 func removeLocalFile(id: UUID) { localFiles.removeAll { $0.id == id } } /// 上传本地文件到 OSS,并登记到指定相册。 func submit(scenicId: Int?, api: any AssetsServing, uploadService: any OSSUploadServing) async -> Bool { guard let scenicId else { errorMessage = "缺少当前景区,无法上传" return false } guard let folderId = selectedFolderId else { errorMessage = "请选择相册" return false } guard !localFiles.isEmpty else { errorMessage = "请选择要上传的图片或视频" return false } guard !isSubmitting else { return false } isSubmitting = true didUploadSuccessfully = false uploadProgress = 0 defer { isSubmitting = false } do { let fileCount = max(localFiles.count, 1) for (index, file) in localFiles.enumerated() { let url = try await uploadService.uploadAlbumFile( data: file.data, fileName: file.fileName, fileType: file.fileType, scenicId: scenicId ) { progress in Task { @MainActor in let base = Double(index) / Double(fileCount) let step = Double(progress) / Double(fileCount) self.uploadProgress = min(99, Int((base + step / 100) * 100)) } } try await api.albumFileUploadURL(scenicId: scenicId, fileURL: url, folderId: folderId) } uploadProgress = 100 didUploadSuccessfully = true localFiles = [] return true } catch { errorMessage = error.localizedDescription return false } } } private extension String { /// 空字符串转 nil,方便可选筛选参数传递。 var assetsNilIfEmpty: String? { isEmpty ? nil : self } }