// // TravelAlbumDetailViewModel.swift // suixinkan // import Foundation /// 旅拍相册详情 ViewModel,负责相册信息、素材分页筛选和删除状态。 final class TravelAlbumDetailViewModel { enum Tab: Int, Sendable { case all = 0 case purchased = 1 } /// 排序方式。 enum SortOption: Int, CaseIterable, Sendable { case createdAsc = 1 case createdDesc = 2 case fileNameAsc = 3 case fileNameDesc = 4 var title: String { switch self { case .createdAsc: "创建时间正序" case .createdDesc: "创建时间倒序" case .fileNameAsc: "文件名正序" case .fileNameDesc: "文件名倒序" } } var compactTitle: String { switch self { case .createdAsc: "时间 ↑" case .createdDesc: "时间 ↓" case .fileNameAsc: "名称 ↑" case .fileNameDesc: "名称 ↓" } } } private(set) var album: TravelAlbum? private(set) var materials: [TravelAlbumMaterial] = [] private(set) var allPhotoCount = 0 private(set) var purchasedPhotoCount = 0 private(set) var selectedTab: Tab = .all private(set) var sortOption: SortOption = .createdDesc private(set) var isLoading = true private(set) var isRefreshing = false private(set) var isSelectionMode = false private(set) var selectedMaterialIds: Set = [] private var selectedMaterialPurchaseStates: [Int: Bool] = [:] var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? var onDeletedAlbum: ((Int) -> Void)? let albumId: Int private var currentPage = 1 private var canLoadMore = false private var isLoadingMore = false private let pageSize = 30 init(albumId: Int) { self.albumId = albumId } /// 重新加载详情与素材。 func refreshAll(api: any TravelAlbumServing) async { guard albumId > 0 else { isLoading = false onShowMessage?("相册不存在") notifyStateChange() return } isRefreshing = true notifyStateChange() await loadAlbumInfo(api: api) await loadAllPhotoCount(api: api) await loadPurchasedPhotoCount(api: api) await loadMaterials(reset: true, api: api) isRefreshing = false isLoading = false notifyStateChange() } /// 拉取相册详情。 func loadAlbumInfo(api: any TravelAlbumServing) async { do { album = try await api.info(id: albumId) notifyStateChange() } catch is CancellationError { return } catch { onShowMessage?(error.localizedDescription) } } /// 拉取全部照片数量。 func loadAllPhotoCount(api: any TravelAlbumServing) async { do { let response = try await api.materialList( userEquityTravelId: albumId, page: 1, pageSize: 1, orderBy: sortOption.rawValue, isPurchased: nil ) allPhotoCount = response.total notifyStateChange() } catch is CancellationError { return } catch { return } } /// 拉取已购照片数量;失败时保留已有数量,不影响素材列表加载。 func loadPurchasedPhotoCount(api: any TravelAlbumServing) async { do { let response = try await api.materialList( userEquityTravelId: albumId, page: 1, pageSize: 1, orderBy: sortOption.rawValue, isPurchased: 1 ) purchasedPhotoCount = response.total notifyStateChange() } catch is CancellationError { return } catch { return } } /// 摘要卡封面地址,依次回退相册封面、首张素材封面和首张素材原图。 var displayCoverURL: String { TravelAlbumDisplayFormatter.albumCoverURL(album: album, materials: materials) } /// 当前 Tab 对应的原图项目总数,供全屏预览索引使用。 var currentPhotoCount: Int { selectedTab == .all ? allPhotoCount : purchasedPhotoCount } /// 当前选中素材中的已购买数量。 var selectedPurchasedMaterialCount: Int { selectedMaterialIds.count - selectedDeletableMaterialIds.count } /// 删除确认弹窗文案;选中已购素材时明确说明保护规则与实际删除数量。 var deleteSelectedConfirmationMessage: String { let selectedCount = selectedMaterialIds.count let purchasedCount = selectedPurchasedMaterialCount guard purchasedCount > 0 else { return "确定删除选中的 \(selectedCount) 张素材吗?" } let deletableCount = selectedCount - purchasedCount return "已选择 \(selectedCount) 张素材,其中 \(purchasedCount) 张已购买。" + "不会删除已购买的项目,只会删除 \(deletableCount) 张未购买的项目。" } /// 切换素材 tab。 func selectTab(_ tab: Tab, api: any TravelAlbumServing) async { guard selectedTab != tab else { return } selectedTab = tab isSelectionMode = false clearMaterialSelection() notifyStateChange() await loadMaterials(reset: true, api: api) } /// 切换排序。 func setSortOption(_ option: SortOption, api: any TravelAlbumServing) async { guard sortOption != option else { return } sortOption = option notifyStateChange() await loadMaterials(reset: true, api: api) } /// 拉取素材列表。 func loadMaterials(reset: Bool, api: any TravelAlbumServing) async { guard albumId > 0 else { return } if reset { currentPage = 1 canLoadMore = false } else { guard canLoadMore, !isLoadingMore else { return } isLoadingMore = true currentPage += 1 } do { let response = try await api.materialList( userEquityTravelId: albumId, page: currentPage, pageSize: pageSize, orderBy: sortOption.rawValue, isPurchased: selectedTab == .purchased ? 1 : nil ) materials = reset ? response.list : materials + response.list canLoadMore = materials.count < response.total if selectedTab == .all { allPhotoCount = response.total } else { purchasedPhotoCount = response.total } isLoadingMore = false notifyStateChange() } catch is CancellationError { return } catch { if !reset && currentPage > 1 { currentPage -= 1 } isLoadingMore = false onShowMessage?(error.localizedDescription) } } /// 根据滚动位置触发加载更多。 func loadMoreIfNeeded(lastVisibleIndex: Int, api: any TravelAlbumServing) async { if lastVisibleIndex >= materials.count - 6 { await loadMaterials(reset: false, api: api) } } /// 定向刷新单个素材,并同步替换网格列表中的对应缓存。 func refreshMaterial(id: Int, api: any TravelAlbumServing) async throws -> TravelAlbumMaterial { let material = try await api.materialInfo( userEquityTravelId: albumId, materialId: id ) guard material.id == id else { throw APIError.invalidResponse } if let index = materials.firstIndex(where: { $0.id == id }) { materials[index] = material notifyStateChange() } return material } /// 切换选择模式。 func toggleSelectionMode() { isSelectionMode.toggle() if !isSelectionMode { clearMaterialSelection() } notifyStateChange() } /// 切换素材选中状态。 func toggleMaterialSelection(_ material: TravelAlbumMaterial) { guard isSelectionMode else { return } if selectedMaterialIds.contains(material.id) { selectedMaterialIds.remove(material.id) selectedMaterialPurchaseStates.removeValue(forKey: material.id) } else { selectedMaterialIds.insert(material.id) selectedMaterialPurchaseStates[material.id] = material.isPurchased } notifyStateChange() } /// AI 修图任务提交成功后退出选择模式并清空当前选择。 func completeAIRetouchSubmission() { isSelectionMode = false clearMaterialSelection() notifyStateChange() } /// AI 修图提交成功后重置选择状态,并刷新相册摘要、数量与当前素材列表。 func refreshAfterAIRetouchSubmission(api: any TravelAlbumServing) async { completeAIRetouchSubmission() await refreshAll(api: api) } /// 预览页删除成功后移除对应素材,并同步全部/已购计数。 func removeMaterialAfterPreviewDeletion(id: Int) { guard let index = materials.firstIndex(where: { $0.id == id }) else { return } let material = materials.remove(at: index) selectedMaterialIds.remove(id) selectedMaterialPurchaseStates.removeValue(forKey: id) allPhotoCount = max(0, allPhotoCount - 1) if material.isPurchased { purchasedPhotoCount = max(0, purchasedPhotoCount - 1) } notifyStateChange() } /// 删除已选素材。 func deleteSelectedMaterials(api: any TravelAlbumServing) async { guard !selectedMaterialIds.isEmpty else { onShowMessage?("请选择要删除的素材") return } let ids = selectedDeletableMaterialIds guard !ids.isEmpty else { onShowMessage?("选中的素材均已购买,未删除任何项目") return } do { try await api.batchDeleteMaterials(ids: ids) onShowMessage?("删除成功") isSelectionMode = false clearMaterialSelection() await refreshAll(api: api) } catch is CancellationError { return } catch { onShowMessage?(error.localizedDescription.isEmpty ? "删除失败" : error.localizedDescription) } } /// 删除当前相册。 func deleteAlbum(api: any TravelAlbumServing) async { do { try await api.deleteAlbum(id: albumId) onShowMessage?("相册已删除") onDeletedAlbum?(albumId) } catch is CancellationError { return } catch { onShowMessage?(error.localizedDescription.isEmpty ? "删除失败" : error.localizedDescription) } } private func notifyStateChange() { onStateChange?() } private func clearMaterialSelection() { selectedMaterialIds = [] selectedMaterialPurchaseStates = [:] } private var selectedDeletableMaterialIds: [Int] { selectedMaterialIds .filter { id in guard selectedMaterialPurchaseStates[id] == false else { return false } return materials.first(where: { $0.id == id })?.isPurchased != true } .sorted() } }