// // MaterialManagementViewModels.swift // suixinkan // import Foundation /// 素材 OSS 上传协议,便于上传和编辑 ViewModel 在测试中注入替身。 @MainActor protocol MaterialOSSUploading { /// 上传素材文件并返回 OSS URL。 func uploadMaterialFile( data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void ) async throws -> String } /// 素材列表页 ViewModel,负责搜索、筛选、分页和上下架操作。 final class MaterialListViewModel { private(set) var items: [MaterialItem] = [] private(set) var isLoading = false private(set) var isRefreshing = false private(set) var canLoadMore = false private(set) var filterStatus: MaterialFilterStatus = .all private(set) var searchText = "" private(set) var orderInfo = MaterialOrderInfo() var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? private var currentPage = 1 private var totalCount = 0 private let pageSize = 10 /// 首次加载素材列表。 func loadInitial(api: any MaterialManagementServing) async { await load(reset: true, showLoading: true, api: api) } /// 下拉刷新素材列表。 func refresh(api: any MaterialManagementServing) async { isRefreshing = true notifyStateChange() await load(reset: true, showLoading: false, api: api) } /// 滚动到底部附近时加载更多。 func loadMoreIfNeeded(lastVisibleIndex: Int, api: any MaterialManagementServing) async { guard lastVisibleIndex >= items.count - 3 else { return } await load(reset: false, showLoading: false, api: api) } /// 更新搜索文本并重新加载第一页。 func updateSearchText(_ text: String, api: any MaterialManagementServing) async { searchText = text await load(reset: true, showLoading: false, api: api) } /// 选择筛选状态并重新加载第一页。 func selectFilter(_ status: MaterialFilterStatus, api: any MaterialManagementServing) async { guard filterStatus != status else { return } filterStatus = status notifyStateChange() await load(reset: true, showLoading: false, api: api) } /// 切换素材上下架状态。 func toggleListing(materialId: Int, checked: Bool, api: any MaterialManagementServing) async { guard let currentItem = items.first(where: { $0.id == materialId }) else { return } guard currentItem.status == 1 else { onShowMessage?("审核通过才可以操作上下架") notifyStateChange() return } let targetStatus = checked ? 1 : 0 guard currentItem.listingStatus != targetStatus else { return } do { try await api.setListingStatus(id: materialId, listingStatus: targetStatus) items = items.map { item in guard item.id == materialId else { return item } return MaterialItem( 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) } } /// 从列表中移除已删除素材。 func removeMaterial(id: Int) { items.removeAll { $0.id == id } totalCount = max(0, totalCount - 1) canLoadMore = items.count < totalCount notifyStateChange() } private func load(reset: Bool, showLoading: Bool, api: any MaterialManagementServing) 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).sxkNonEmpty let response = try await api.list( status: filterStatus.apiStatus, keyword: keyword, page: currentPage, pageSize: pageSize ) totalCount = response.total orderInfo = response.order ?? MaterialOrderInfo() if reset { items = response.list } else { let existingIDs = Set(items.map(\.id)) items.append(contentsOf: response.list.filter { !existingIDs.contains($0.id) }) } 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 MaterialDetailViewModel { private(set) var detail: MaterialDetail? private(set) var isLoading = false private(set) var isDeleting = false var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? var onDeleted: ((Int) -> Void)? let materialId: Int /// 初始化素材详情 ViewModel。 init(materialId: Int) { self.materialId = materialId } /// 加载素材详情。 func load(api: any MaterialManagementServing) async { guard materialId > 0 else { onShowMessage?("素材ID无效") return } isLoading = true notifyStateChange() defer { isLoading = false notifyStateChange() } do { detail = try await api.detail(id: materialId) } catch is CancellationError { return } catch { onShowMessage?(error.localizedDescription.isEmpty ? "获取素材详情失败" : error.localizedDescription) } } /// 删除当前素材。 func delete(api: any MaterialManagementServing) async { guard materialId > 0 else { onShowMessage?("素材ID无效") return } isDeleting = true notifyStateChange() defer { isDeleting = false notifyStateChange() } do { try await api.delete(id: materialId) onShowMessage?("删除成功") onDeleted?(materialId) } catch is CancellationError { return } catch { onShowMessage?(error.localizedDescription.isEmpty ? "删除失败" : error.localizedDescription) } } private func notifyStateChange() { onStateChange?() } } /// 素材上传与编辑页 ViewModel,负责表单、标签、选择数据、上传进度与提交。 final class MaterialFormViewModel { enum Mode: Equatable { case create case edit(id: Int) } private(set) var materialName = "" private(set) var selectedScenicSpot: ScenicSpotItem? private(set) var scenicSpotList: [ScenicSpotItem] = [] private(set) var mediaType: MaterialMediaType = .image private(set) var uploadedMediaList: [MaterialUploadedMediaItem] = [] private(set) var coverImage: MaterialUploadedMediaItem? private(set) var tagList: [MaterialTagItem] = [] private(set) var tagInput = "" private(set) var pendingDeleteTag: MaterialTagItem? private(set) var uploadDialogState: MaterialUploadDialogState? private(set) var isSubmitting = false private(set) var isLoadingDetail = false var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? var onSubmitSuccess: (() -> Void)? let mode: Mode private let currentScenicIdProvider: () -> Int private var pendingScenicSpotId: Int64? /// 初始化素材表单 ViewModel。 init( mode: Mode = .create, currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId } ) { self.mode = mode self.currentScenicIdProvider = currentScenicIdProvider } /// 初始加载,编辑模式会同时拉详情。 func loadInitial(api: any MaterialManagementServing) async { await loadScenicSpots(api: api) if case .edit(let id) = mode { await loadDetail(id: id, api: api) } } /// 更新素材名称,最多保留 30 个字符。 func updateMaterialName(_ name: String) { materialName = String(name.prefix(30)) notifyStateChange() } /// 选择关联打卡点。 func selectScenicSpot(_ spot: ScenicSpotItem) { selectedScenicSpot = spot notifyStateChange() } /// 切换素材类型,类型变化时清空已选素材。 func selectMediaType(_ type: MaterialMediaType) { guard mediaType != type else { return } mediaType = type uploadedMediaList = [] notifyStateChange() } /// 更新标签输入。 func updateTagInput(_ text: String) { tagInput = String(text.prefix(15)) notifyStateChange() } /// 新增标签。 func addTag(api: any MaterialManagementServing) async { let name = tagInput.trimmingCharacters(in: .whitespacesAndNewlines) guard name.count >= 5, name.count <= 15 else { onShowMessage?("标签长度必须在5-15个字符之间") return } guard tagList.count < 30 else { onShowMessage?("最多只能添加30个标签") return } guard !tagList.contains(where: { $0.name == name }) else { onShowMessage?("标签已存在") return } do { let id = try await api.addTag(name: name) tagList.append(MaterialTagItem(id: id, name: name)) tagInput = "" onShowMessage?("添加标签成功") notifyStateChange() } catch is CancellationError { return } catch { onShowMessage?(error.localizedDescription.isEmpty ? "添加标签失败" : error.localizedDescription) } } /// 请求删除标签,等待用户确认。 func requestDeleteTag(_ tag: MaterialTagItem) { pendingDeleteTag = tag notifyStateChange() } /// 取消删除标签。 func cancelDeleteTag() { pendingDeleteTag = nil notifyStateChange() } /// 确认删除当前待删除标签。 func confirmDeleteTag() { guard let tag = pendingDeleteTag else { return } tagList.removeAll { $0.id == tag.id } pendingDeleteTag = nil notifyStateChange() } /// 增加并立即顺序上传素材。 func addLocalMedia(_ mediaItems: [MaterialUploadedMediaItem], uploader: any MaterialOSSUploading) async { guard !mediaItems.isEmpty else { return } let remaining = mediaType.maxCount - uploadedMediaList.count guard remaining > 0 else { onShowMessage?(mediaType == .video ? "视频只能上传1个" : "图片最多上传9张") return } let accepted = Array(mediaItems.prefix(remaining)) uploadedMediaList.append(contentsOf: accepted) notifyStateChange() for offset in accepted.indices { let index = uploadedMediaList.count - accepted.count + offset await uploadMedia(at: index, title: "正在上传素材(\(offset + 1)/\(accepted.count))", uploader: uploader) } uploadDialogState = nil notifyStateChange() } /// 设置封面并立即上传。 func setCoverImage(_ item: MaterialUploadedMediaItem, uploader: any MaterialOSSUploading) async { coverImage = item notifyStateChange() await uploadCover(uploader: uploader) uploadDialogState = nil notifyStateChange() } /// 删除素材。 func deleteMaterial(at index: Int) { guard uploadedMediaList.indices.contains(index) else { return } uploadedMediaList.remove(at: index) notifyStateChange() } /// 删除封面。 func deleteCoverImage() { coverImage = nil notifyStateChange() } /// 上传或编辑素材。 func submit(api: any MaterialManagementServing) async { guard !materialName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { onShowMessage?("请输入素材名称") return } guard materialName.count <= 30 else { onShowMessage?("素材名称最多30个字符") return } guard !uploadedMediaList.isEmpty else { onShowMessage?("请至少上传一个素材") return } guard let coverImage else { onShowMessage?("请上传封面图片") return } guard let selectedScenicSpot else { onShowMessage?("请选择关联打卡点") return } guard !uploadedMediaList.contains(where: { $0.ossUrl.isEmpty }) else { onShowMessage?("请等待素材上传完成") return } guard !coverImage.ossUrl.isEmpty else { onShowMessage?("请等待封面图片上传完成") return } let request = MaterialUploadRequest( id: editID, name: materialName, mediaType: mediaType, mediaList: uploadedMediaList.map { MaterialUploadMediaItem( originalName: $0.fileName, ossUrl: $0.ossUrl, size: $0.size, fileWidthSize: MaterialUploadFileSize(width: $0.width, height: $0.height) ) }, coverUrl: coverImage.ossUrl, coverSize: String(coverImage.size), scenicSpotId: selectedScenicSpot.id, materialTag: tagList.filter { $0.id > 0 }.map { String($0.id) }.joined(separator: ",") ) isSubmitting = true notifyStateChange() defer { isSubmitting = false notifyStateChange() } do { switch mode { case .create: try await api.upload(request) onShowMessage?("上传成功") case .edit: try await api.edit(request) onShowMessage?("编辑成功") } onSubmitSuccess?() } catch is CancellationError { return } catch { onShowMessage?(error.localizedDescription.isEmpty ? "提交失败" : error.localizedDescription) } } private var editID: Int? { if case .edit(let id) = mode { return id } return nil } private func loadScenicSpots(api: any MaterialManagementServing) async { let scenicId = currentScenicIdProvider() guard scenicId > 0 else { onShowMessage?("请先选择景区") return } do { let response = try await api.scenicSpotListAll(scenicId: String(scenicId)) scenicSpotList = response.list if let pendingScenicSpotId { selectedScenicSpot = scenicSpotList.first { $0.id == pendingScenicSpotId } } notifyStateChange() } catch is CancellationError { return } catch { onShowMessage?(error.localizedDescription.isEmpty ? "获取打卡点失败" : error.localizedDescription) } } private func loadDetail(id: Int, api: any MaterialManagementServing) async { isLoadingDetail = true notifyStateChange() defer { isLoadingDetail = false notifyStateChange() } do { let detail = try await api.detail(id: id) materialName = detail.name ?? "" mediaType = detail.mediaList?.first?.type == 2 ? .video : .image pendingScenicSpotId = detail.scenicSpotId selectedScenicSpot = scenicSpotList.first { $0.id == detail.scenicSpotId } tagList = detail.materialTag ?? [] uploadedMediaList = detail.mediaList?.map { media in MaterialUploadedMediaItem( data: Data(), thumbnailData: nil, previewURL: media.displayURL, fileName: media.originalName ?? "material", size: media.size ?? 0, width: media.width ?? 0, height: media.height ?? 0, ossUrl: media.ossUrl ?? media.showUrl ?? "", isUploading: false, uploadProgress: 100 ) } ?? [] if let coverUrl = detail.coverUrl, !coverUrl.isEmpty { coverImage = MaterialUploadedMediaItem( data: Data(), previewURL: coverUrl, fileName: "cover", size: Int64(detail.coverSize ?? 0), ossUrl: coverUrl, isUploading: false, uploadProgress: 100 ) } } catch is CancellationError { return } catch { onShowMessage?(error.localizedDescription.isEmpty ? "加载素材详情失败" : error.localizedDescription) } } private func uploadMedia(at index: Int, title: String, uploader: any MaterialOSSUploading) async { guard uploadedMediaList.indices.contains(index) else { return } guard currentScenicIdProvider() > 0 else { onShowMessage?("请先选择景区") return } uploadedMediaList[index].isUploading = true uploadedMediaList[index].uploadProgress = 0 uploadDialogState = MaterialUploadDialogState(title: title, progress: 0) notifyStateChange() do { let item = uploadedMediaList[index] let url = try await uploader.uploadMaterialFile( data: item.data, fileName: item.fileName, fileType: mediaType.uploadFileType, scenicId: currentScenicIdProvider(), onProgress: { [weak self] progress in guard let self else { return } Task { @MainActor in guard self.uploadedMediaList.indices.contains(index) else { return } let clampedProgress = max(0, min(100, progress)) self.uploadedMediaList[index].uploadProgress = clampedProgress self.uploadedMediaList[index].isUploading = clampedProgress < 100 if clampedProgress < 100 { self.uploadDialogState = MaterialUploadDialogState(title: title, progress: clampedProgress) } self.notifyStateChange() } } ) guard uploadedMediaList.indices.contains(index) else { return } uploadedMediaList[index].ossUrl = url uploadedMediaList[index].isUploading = false uploadedMediaList[index].uploadProgress = 100 } catch is CancellationError { return } catch { guard uploadedMediaList.indices.contains(index) else { return } uploadedMediaList[index].isUploading = false uploadedMediaList[index].uploadProgress = 0 onShowMessage?(error.localizedDescription.isEmpty ? "上传失败" : error.localizedDescription) } notifyStateChange() } private func uploadCover(uploader: any MaterialOSSUploading) async { guard let coverImage else { return } guard currentScenicIdProvider() > 0 else { onShowMessage?("请先选择景区") return } self.coverImage?.isUploading = true self.coverImage?.uploadProgress = 0 uploadDialogState = MaterialUploadDialogState(title: "正在上传封面", progress: 0) notifyStateChange() do { let url = try await uploader.uploadMaterialFile( data: coverImage.data, fileName: coverImage.fileName, fileType: 2, scenicId: currentScenicIdProvider(), onProgress: { [weak self] progress in guard let self else { return } Task { @MainActor in let clampedProgress = max(0, min(100, progress)) self.coverImage?.uploadProgress = clampedProgress self.coverImage?.isUploading = clampedProgress < 100 if clampedProgress < 100 { self.uploadDialogState = MaterialUploadDialogState(title: "正在上传封面", progress: clampedProgress) } self.notifyStateChange() } } ) self.coverImage?.ossUrl = url self.coverImage?.isUploading = false self.coverImage?.uploadProgress = 100 } catch is CancellationError { return } catch { self.coverImage?.isUploading = false self.coverImage?.uploadProgress = 0 onShowMessage?(error.localizedDescription.isEmpty ? "封面上传失败" : error.localizedDescription) } } private func notifyStateChange() { onStateChange?() } } private extension String { var sxkNonEmpty: String? { isEmpty ? nil : self } }