// // ScenicApplicationViewModel.swift // suixinkan // import Foundation /// 景区申请图片上传协议,便于单元测试注入 mock。 @MainActor protocol ScenicApplicationImageUploading: AnyObject { func uploadScenicApplyImage( data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void ) async throws -> String } extension OSSUploadService: ScenicApplicationImageUploading {} /// 景区申请 ViewModel,对齐 Android `ScenicApplicationViewModel`。 final class ScenicApplicationViewModel { private struct UploadBatch { let id: UUID var indices: [Int] var position: Int = 0 var totalCount: Int var completedCount: Int = 0 } private(set) var scenicName = "" private(set) var selectedImages: [ScenicApplicationImageItem] = [] private(set) var selectedProvince = "" private(set) var selectedCity = "" private(set) var selectedCooperationType: ScenicCooperationType = .photographer private(set) var remarks = "" private(set) var isAgreed = false private(set) var isSubmitting = false private(set) var provinceList: [AreasResponse] = [] private(set) var cityList: [AreasResponse] = [] private(set) var pendingApplication: ScenicApplicationPendingResponse? private(set) var uploadDialogState: ScenicApplicationUploadDialogState? var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? var onSubmitSuccess: (() -> Void)? private var uploadBatchQueue: [UploadBatch] = [] private var currentBatch: UploadBatch? private var uploadingIndex: Int? private var isProcessingUploadQueue = false private var lastProgressUpdateTime: TimeInterval = 0 private let progressUpdateInterval: TimeInterval = 0.2 /// 是否满足提交条件,对齐 Android `ScenicApplicationUiState.canSubmit`。 var canSubmit: Bool { !selectedProvince.isEmpty && !selectedCity.isEmpty && !remarks.isEmpty && remarks.count <= 50 && isAgreed && !isSubmitting } /// status == 1 时为只读。 var isReadOnly: Bool { pendingApplication?.status == 1 } /// 是否可编辑表单。 var isEditable: Bool { guard let pending = pendingApplication else { return true } return pending.status == 2 || pending.status == 3 || pending.status == 9 } /// 是否展示审核状态卡片(status 1/3/9)。 var shouldShowAuditCard: Bool { guard let pending = pendingApplication else { return false } return pending.status == 1 || pending.status == 3 || pending.status == 9 } /// 初始化加载省市与待审核数据。 func loadInitial(profileAPI: ProfileAPI, homeAPI: HomeAPI) async { async let areasTask: Void = loadAreas(profileAPI: profileAPI) async let pendingTask: Void = loadPendingApplication(homeAPI: homeAPI) _ = await (areasTask, pendingTask) } func loadAreas(profileAPI: ProfileAPI) async { do { provinceList = try await profileAPI.areas() notifyStateChange() } catch { onShowMessage?("获取省市数据失败: \(error.localizedDescription)") } } func loadPendingApplication(homeAPI: HomeAPI) async { do { let response = try await homeAPI.scenicApplicationPending() guard let pending = response.items.first else { pendingApplication = nil notifyStateChange() return } if pending.status == 1 || pending.status == 3 { pendingApplication = pending await waitForProvinceListIfNeeded() initWithPendingApplication(pending) } else { pendingApplication = nil } notifyStateChange() } catch { pendingApplication = nil notifyStateChange() } } func onScenicNameChange(_ name: String) { scenicName = name notifyStateChange() } func onProvinceSelected(_ provinceName: String) { let province = provinceList.first { $0.name == provinceName } selectedProvince = provinceName selectedCity = "" cityList = province?.children ?? [] notifyStateChange() } func onCitySelected(_ cityName: String) { selectedCity = cityName notifyStateChange() } func onCooperationTypeSelected(_ type: ScenicCooperationType) { selectedCooperationType = type notifyStateChange() } func onRemarksChange(_ text: String) { if text.count <= 50 { remarks = text notifyStateChange() } else { onShowMessage?("备注信息最多50字") } } func toggleAgreement() { isAgreed.toggle() notifyStateChange() } /// 添加本地图片并排队上传。 func addLocalImages(_ items: [ScenicApplicationImageItem]) { guard !items.isEmpty else { return } let startIndex = selectedImages.count selectedImages.append(contentsOf: items) let indices = (0..= 0, index < selectedImages.count else { return } selectedImages.remove(at: index) adjustBatchAfterDeletion(deletedIndex: index) if uploadingIndex == index { uploadingIndex = nil uploadDialogState = nil } else if let uploadingIndex, index < uploadingIndex { self.uploadingIndex = uploadingIndex - 1 } notifyStateChange() } func retryUpload(at index: Int) { guard index >= 0, index < selectedImages.count else { return } selectedImages[index].errorMessage = nil uploadBatchQueue.insert( UploadBatch(id: UUID(), indices: [index], totalCount: 1), at: 0 ) notifyStateChange() } /// 顺序处理待上传图片队列。 func processPendingUploads(uploader: ScenicApplicationImageUploading, scenicId: Int) async { guard !isProcessingUploadQueue else { return } isProcessingUploadQueue = true defer { isProcessingUploadQueue = false } while true { if currentBatch == nil || (currentBatch?.position ?? 0) >= (currentBatch?.indices.count ?? 0) { currentBatch = uploadBatchQueue.isEmpty ? nil : uploadBatchQueue.removeFirst() } guard var batch = currentBatch else { uploadDialogState = nil notifyStateChange() return } while batch.position < batch.indices.count { let index = batch.indices[batch.position] guard index < selectedImages.count else { batch.indices.remove(at: batch.position) batch.totalCount = max(batch.completedCount, batch.totalCount - 1) continue } let image = selectedImages[index] if image.isUploaded { batch.position += 1 batch.completedCount = min(batch.totalCount, batch.completedCount + 1) continue } if image.errorMessage != nil { batch.position += 1 continue } guard let data = image.localData else { batch.position += 1 continue } currentBatch = batch uploadingIndex = index updateImage(at: index) { item in var copy = item copy.isUploading = true copy.uploadProgress = 0 copy.errorMessage = nil return copy } let total = max(batch.totalCount, 1) let currentPosition = min(batch.completedCount + 1, total) uploadDialogState = ScenicApplicationUploadDialogState( title: "正在上传(\(currentPosition)/\(total))", progress: 0 ) notifyStateChange() do { let url = try await uploader.uploadScenicApplyImage( data: data, fileName: image.fileName, scenicId: scenicId, onProgress: { [weak self] progress in Task { @MainActor in self?.handleUploadProgress( index: index, batch: batch, currentPosition: currentPosition, total: total, progress: progress ) } } ) updateImage(at: index) { item in var copy = item copy.isUploading = false copy.uploadProgress = 100 copy.remoteURL = url copy.errorMessage = nil return copy } batch.completedCount = min(batch.totalCount, batch.completedCount + 1) } catch { updateImage(at: index) { item in var copy = item copy.isUploading = false copy.uploadProgress = 0 copy.errorMessage = error.localizedDescription.isEmpty ? "上传失败" : error.localizedDescription return copy } onShowMessage?("图片上传失败,请重试") } batch.position += 1 uploadingIndex = nil currentBatch = batch } currentBatch = nil uploadDialogState = nil notifyStateChange() } } func submit(homeAPI: HomeAPI) async { guard canSubmit else { onShowMessage?("请完善必填信息") return } if selectedImages.contains(where: \.isUploading) { onShowMessage?("图片正在上传,请稍后提交") return } if selectedImages.contains(where: { !$0.isUploaded && $0.errorMessage == nil && $0.localData != nil }) { onShowMessage?("请先完成图片上传") return } isSubmitting = true notifyStateChange() defer { isSubmitting = false notifyStateChange() } let uploadedURLs = selectedImages.compactMap { item -> String? in item.isUploaded ? item.remoteURL : nil } let request = ScenicApplicationSubmitRequest( scenicName: scenicName.isEmpty ? nil : scenicName, scenicImages: uploadedURLs.isEmpty ? nil : uploadedURLs, scenicProvince: selectedProvince, scenicCity: selectedCity, coopType: selectedCooperationType.rawValue, remark: remarks, scenicId: 0 ) do { try await homeAPI.submitScenicApplication(request) onShowMessage?("提交成功") await loadPendingApplication(homeAPI: homeAPI) onSubmitSuccess?() } catch { onShowMessage?("提交失败: \(error.localizedDescription)") } } private func initWithPendingApplication(_ pending: ScenicApplicationPendingResponse) { let imageStates = pending.scenicImages .split(separator: ",") .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } .map { ScenicApplicationImageItem.fromRemoteURL($0) } let province = provinceList.first { $0.name == pending.scenicProvince } scenicName = pending.scenicName selectedImages = imageStates selectedProvince = pending.scenicProvince selectedCity = pending.scenicCity selectedCooperationType = ScenicCooperationType.from(rawValue: pending.coopType) remarks = pending.remark ?? "" cityList = province?.children ?? [] } private func waitForProvinceListIfNeeded() async { var waitCount = 0 while provinceList.isEmpty && waitCount < 30 { try? await Task.sleep(nanoseconds: 100_000_000) waitCount += 1 } } private func handleUploadProgress( index: Int, batch: UploadBatch, currentPosition: Int, total: Int, progress: Int ) { let now = Date().timeIntervalSince1970 let shouldUpdate = now - lastProgressUpdateTime >= progressUpdateInterval || progress == 0 || progress == 100 guard shouldUpdate else { return } lastProgressUpdateTime = now updateImage(at: index) { item in var copy = item copy.uploadProgress = min(max(progress, 0), 100) return copy } uploadDialogState = ScenicApplicationUploadDialogState( title: "正在上传(\(currentPosition)/\(total))", progress: min(max(progress, 0), 100) ) notifyStateChange() } private func updateImage(at index: Int, transform: (ScenicApplicationImageItem) -> ScenicApplicationImageItem) { guard index >= 0, index < selectedImages.count else { return } selectedImages[index] = transform(selectedImages[index]) } private func adjustBatchAfterDeletion(deletedIndex: Int) { func adjust(_ batch: inout UploadBatch) { var removedBefore = 0 var index = 0 while index < batch.indices.count { let value = batch.indices[index] if value == deletedIndex { if index < batch.position { removedBefore += 1 } batch.indices.remove(at: index) if batch.totalCount > batch.completedCount { batch.totalCount = max(batch.completedCount, batch.totalCount - 1) } } else if value > deletedIndex { batch.indices[index] = value - 1 index += 1 } else { index += 1 } } batch.position = max(0, batch.position - removedBefore) if batch.position > batch.indices.count { batch.position = batch.indices.count } } if var batch = currentBatch { adjust(&batch) if batch.indices.isEmpty { currentBatch = nil uploadDialogState = nil } else { currentBatch = batch } } uploadBatchQueue = uploadBatchQueue.compactMap { batch in var copy = batch adjust(©) return copy.indices.isEmpty ? nil : copy } } private func notifyStateChange() { onStateChange?() } }