// // SampleManagementViewModels.swift // suixinkan // import Foundation /// 样片 OSS 上传协议,便于上传 ViewModel 在测试中注入替身。 @MainActor protocol SampleOSSUploading { /// 上传样片文件并返回 OSS URL。 func uploadSampleFile( data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void ) async throws -> String } /// 样片列表页 ViewModel,负责搜索、筛选、分页和上下架操作。 final class SampleListViewModel { private(set) var items: [SampleMaterialItem] = [] private(set) var isLoading = false private(set) var isRefreshing = false private(set) var canLoadMore = false private(set) var filterStatus: SampleFilterStatus = .all private(set) var searchText = "" var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? private var currentPage = 1 private var totalCount = 0 private let pageSize = 10 /// 首次加载样片列表。 func loadInitial(api: any SampleManagementServing) async { await load(reset: true, showLoading: true, api: api) } /// 下拉刷新样片列表。 func refresh(api: any SampleManagementServing) async { isRefreshing = true notifyStateChange() await load(reset: true, showLoading: false, api: api) } /// 滚动到底部附近时加载更多。 func loadMoreIfNeeded(lastVisibleIndex: Int, api: any SampleManagementServing) async { guard lastVisibleIndex >= items.count - 3 else { return } await load(reset: false, showLoading: false, api: api) } /// 更新搜索文本并重新加载第一页。 func updateSearchText(_ text: String, api: any SampleManagementServing) async { searchText = text await load(reset: true, showLoading: false, api: api) } /// 选择筛选状态并重新加载第一页。 func selectFilter(_ status: SampleFilterStatus, api: any SampleManagementServing) async { guard filterStatus != status else { return } filterStatus = status notifyStateChange() await load(reset: true, showLoading: false, api: api) } /// 切换样片上下架状态。 func toggleListing(sampleId: Int, checked: Bool, api: any SampleManagementServing) async { guard let currentItem = items.first(where: { $0.id == sampleId }) else { return } guard currentItem.status == 1 else { onShowMessage?("审核通过才可以操作上下架") return } let targetStatus = checked ? 1 : 0 guard currentItem.listingStatus != targetStatus else { return } do { try await api.setListingStatus(id: sampleId, listingStatus: targetStatus) items = items.map { item in guard item.id == sampleId else { return item } return SampleMaterialItem( id: item.id, name: item.name, coverUrl: item.coverUrl, createdAt: item.createdAt, status: item.status, downloadCount: item.downloadCount, likesCount: item.likesCount, collectCount: item.collectCount, shareCount: item.shareCount, scenicSpotName: item.scenicSpotName, projectName: item.projectName, listingStatus: targetStatus ) } notifyStateChange() } catch is CancellationError { return } catch { onShowMessage?(error.localizedDescription.isEmpty ? "操作失败" : error.localizedDescription) } } private func load(reset: Bool, showLoading: Bool, api: any SampleManagementServing) async { if reset { currentPage = 1 canLoadMore = false } else { guard canLoadMore, !isLoading else { return } currentPage += 1 } isLoading = showLoading notifyStateChange() defer { isLoading = false isRefreshing = false notifyStateChange() } do { let keyword = searchText.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty let response = try await api.list( status: filterStatus.apiStatus, keyword: keyword, page: currentPage, pageSize: pageSize ) totalCount = response.total items = reset ? response.list : items + response.list canLoadMore = items.count < totalCount } catch is CancellationError { return } catch { if !reset, currentPage > 1 { currentPage -= 1 } if reset { items = [] totalCount = 0 canLoadMore = false } onShowMessage?(error.localizedDescription) } } private func notifyStateChange() { onStateChange?() } } /// 样片详情页 ViewModel,负责详情加载状态。 final class SampleDetailViewModel { private(set) var detail: SampleDetail? private(set) var isLoading = false var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? let sampleId: Int /// 初始化样片详情 ViewModel。 init(sampleId: Int) { self.sampleId = sampleId } /// 加载样片详情。 func load(api: any SampleManagementServing) async { guard sampleId > 0 else { onShowMessage?("样片ID无效") return } isLoading = true notifyStateChange() defer { isLoading = false notifyStateChange() } do { detail = try await api.detail(id: sampleId) } catch is CancellationError { return } catch { onShowMessage?(error.localizedDescription.isEmpty ? "获取样片详情失败" : error.localizedDescription) } } private func notifyStateChange() { onStateChange?() } } /// 样片上传页 ViewModel,负责表单、选择数据、上传进度与提交。 final class UploadSampleViewModel { private(set) var albumName = "" private(set) var selectedScenic: ScenicInfo? private(set) var scenicList: [ScenicInfo] = [] private(set) var selectedScenicSpot: ScenicSpotItem? private(set) var scenicSpotList: [ScenicSpotItem] = [] private(set) var selectedProject: SampleProjectItem? private(set) var projectList: [SampleProjectItem] = [] private(set) var projectCanLoadMore = false private(set) var mediaType: SampleMediaType = .image private(set) var uploadedMediaList: [SampleUploadedMediaItem] = [] private(set) var coverImage: SampleUploadedMediaItem? private(set) var uploadDialogState: SampleUploadDialogState? private(set) var isSubmitting = false var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? var onUploadSuccess: (() -> Void)? private let scenicListProvider: () -> [ScenicInfo] private let currentScenicIdProvider: () -> Int private var projectCurrentPage = 1 private var projectTotal = 0 private var projectIsLoading = false private let projectPageSize = 10 /// 初始化样片上传 ViewModel。 init( scenicListProvider: @escaping () -> [ScenicInfo] = { AppStore.shared.permissions.roleScenicList() }, currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId } ) { self.scenicListProvider = scenicListProvider self.currentScenicIdProvider = currentScenicIdProvider scenicList = scenicListProvider() } /// 更新相册名称,最多保留 30 个字符。 func updateAlbumName(_ name: String) { albumName = String(name.prefix(30)) notifyStateChange() } /// 选择关联景区,并清空打卡点和项目选择。 func selectScenic(_ scenic: ScenicInfo, api: any SampleManagementServing) async { selectedScenic = scenic selectedScenicSpot = nil scenicSpotList = [] selectedProject = nil projectList = [] projectCurrentPage = 1 projectTotal = 0 projectCanLoadMore = false notifyStateChange() await loadScenicSpots(api: api) } /// 选择关联打卡点。 func selectScenicSpot(_ spot: ScenicSpotItem) { selectedScenicSpot = spot notifyStateChange() } /// 选择关联项目。 func selectProject(_ project: SampleProjectItem) { selectedProject = project notifyStateChange() } /// 切换素材类型,类型变化时清空已选素材。 func selectMediaType(_ type: SampleMediaType) { guard mediaType != type else { return } mediaType = type uploadedMediaList = [] notifyStateChange() } /// 加载项目列表。 func loadProjects(refresh: Bool, api: any SampleManagementServing) async { guard let scenicId = selectedScenic?.id, scenicId > 0 else { onShowMessage?("请先选择景区") return } if projectIsLoading && !refresh { return } if refresh { projectCurrentPage = 1 projectList = [] projectCanLoadMore = false notifyStateChange() } else { guard projectCanLoadMore else { return } } projectIsLoading = true defer { projectIsLoading = false notifyStateChange() } do { let response = try await api.projectList( scenicId: String(scenicId), name: nil, page: projectCurrentPage, pageSize: projectPageSize ) projectTotal = response.total projectList = refresh ? response.list : projectList + response.list projectCurrentPage += 1 projectCanLoadMore = projectList.count < projectTotal } catch is CancellationError { return } catch { onShowMessage?(error.localizedDescription.isEmpty ? "获取项目列表失败" : error.localizedDescription) } } /// 增加并立即顺序上传样片素材。 func addLocalMedia(_ mediaItems: [SampleUploadedMediaItem], uploader: any SampleOSSUploading) async { guard !mediaItems.isEmpty else { return } let remaining = mediaType.maxCount - uploadedMediaList.count guard remaining > 0 else { onShowMessage?(mediaType == .video ? "视频只能上传1个" : "图片最多上传9张") return } let acceptedItems = Array(mediaItems.prefix(remaining)) if acceptedItems.count < mediaItems.count { onShowMessage?(mediaType == .video ? "视频只能上传1个" : "图片最多上传9张") } let startIndex = uploadedMediaList.count uploadedMediaList.append(contentsOf: acceptedItems) notifyStateChange() for offset in acceptedItems.indices { let index = startIndex + offset await uploadMedia(at: index, batchPosition: offset + 1, batchTotal: acceptedItems.count, uploader: uploader) } uploadDialogState = nil notifyStateChange() } /// 设置封面并立即上传。 func setCoverImage(_ image: SampleUploadedMediaItem, uploader: any SampleOSSUploading) async { coverImage = image notifyStateChange() await uploadCover(uploader: uploader) } /// 删除素材。 func deleteMaterial(at index: Int) { guard uploadedMediaList.indices.contains(index) else { return } uploadedMediaList.remove(at: index) notifyStateChange() } /// 删除封面。 func deleteCoverImage() { coverImage = nil notifyStateChange() } /// 校验并提交样片。 func uploadSample(api: any SampleManagementServing) async { let trimmedName = albumName.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedName.isEmpty else { onShowMessage?("请输入相册名称") return } guard trimmedName.count <= 30 else { onShowMessage?("相册名称最多30个字符") return } guard !uploadedMediaList.isEmpty else { onShowMessage?("请至少上传一个素材") return } guard let coverImage else { onShowMessage?("请上传封面图片") return } guard selectedScenic != nil else { onShowMessage?("请选择关联景区") return } guard let selectedScenicSpot else { onShowMessage?("请选择关联打卡点") return } guard let selectedProject else { onShowMessage?("请选择关联项目") return } guard uploadedMediaList.allSatisfy({ !$0.ossUrl.isEmpty }) else { onShowMessage?("请等待素材上传完成") return } guard !coverImage.ossUrl.isEmpty else { onShowMessage?("请等待封面图片上传完成") return } let mediaList = uploadedMediaList.map { item in SampleUploadMediaItem( originalName: item.fileName, ossUrl: item.ossUrl, size: item.size, fileWidthSize: SampleUploadFileSize(width: item.width, height: item.height) ) } let request = SampleUploadMaterialRequest( name: trimmedName, mediaType: mediaType, mediaList: mediaList, coverUrl: coverImage.ossUrl, coverSize: String(coverImage.size), scenicSpotId: selectedScenicSpot.id, projectId: selectedProject.id ) isSubmitting = true notifyStateChange() defer { isSubmitting = false notifyStateChange() } do { try await api.upload(request) onShowMessage?("上传成功") onUploadSuccess?() } catch is CancellationError { return } catch { onShowMessage?(error.localizedDescription.isEmpty ? "上传失败" : error.localizedDescription) } } private func loadScenicSpots(api: any SampleManagementServing) async { guard let scenicId = selectedScenic?.id, scenicId > 0 else { return } do { let response = try await api.scenicSpotListAll(scenicId: String(scenicId)) scenicSpotList = response.list notifyStateChange() } catch is CancellationError { return } catch { onShowMessage?(error.localizedDescription.isEmpty ? "获取打卡点失败" : error.localizedDescription) } } private func uploadMedia( at index: Int, batchPosition: Int, batchTotal: Int, uploader: any SampleOSSUploading ) async { guard uploadedMediaList.indices.contains(index) else { return } let scenicId = currentScenicIdProvider() guard scenicId > 0 else { onShowMessage?("请先选择景区") return } uploadedMediaList[index].isUploading = true uploadedMediaList[index].uploadProgress = 0 uploadDialogState = SampleUploadDialogState(title: "正在上传素材(\(batchPosition)/\(batchTotal))", progress: 0) notifyStateChange() let item = uploadedMediaList[index] do { let url = try await uploader.uploadSampleFile( data: item.data, fileName: item.fileName, fileType: mediaType.uploadFileType, scenicId: scenicId ) { [weak self] progress in guard let self else { return } Task { @MainActor in guard self.uploadedMediaList.indices.contains(index) else { return } self.uploadedMediaList[index].uploadProgress = progress self.uploadedMediaList[index].isUploading = progress < 100 self.uploadDialogState = SampleUploadDialogState( title: "正在上传素材(\(batchPosition)/\(batchTotal))", progress: max(0, min(100, progress)) ) self.notifyStateChange() } } guard uploadedMediaList.indices.contains(index) else { return } uploadedMediaList[index].ossUrl = url uploadedMediaList[index].isUploading = false uploadedMediaList[index].uploadProgress = 100 notifyStateChange() } catch is CancellationError { return } catch { guard uploadedMediaList.indices.contains(index) else { return } uploadedMediaList[index].isUploading = false uploadedMediaList[index].uploadProgress = 0 onShowMessage?("上传失败: \(error.localizedDescription)") notifyStateChange() } } private func uploadCover(uploader: any SampleOSSUploading) async { guard var image = coverImage else { return } let scenicId = currentScenicIdProvider() guard scenicId > 0 else { onShowMessage?("请先选择景区") return } image.isUploading = true image.uploadProgress = 0 coverImage = image uploadDialogState = SampleUploadDialogState(title: "正在上传封面", progress: 0) notifyStateChange() do { let url = try await uploader.uploadSampleFile( data: image.data, fileName: image.fileName, fileType: SampleMediaType.image.uploadFileType, scenicId: scenicId ) { [weak self] progress in guard let self else { return } Task { @MainActor in guard var current = self.coverImage else { return } current.uploadProgress = progress current.isUploading = progress < 100 self.coverImage = current self.uploadDialogState = SampleUploadDialogState( title: "正在上传封面", progress: max(0, min(100, progress)) ) self.notifyStateChange() } } guard var current = coverImage else { return } current.ossUrl = url current.isUploading = false current.uploadProgress = 100 coverImage = current uploadDialogState = nil notifyStateChange() } catch is CancellationError { return } catch { guard var current = coverImage else { return } current.isUploading = false current.uploadProgress = 0 coverImage = current uploadDialogState = nil onShowMessage?("封面上传失败: \(error.localizedDescription)") notifyStateChange() } } private func notifyStateChange() { onStateChange?() } } private extension String { var nonEmpty: String? { isEmpty ? nil : self } }