Files
suixinkan_uikit/suixinkan/Features/ScenicApplication/ViewModels/ScenicApplicationViewModel.swift

445 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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..<items.count).map { startIndex + $0 }
uploadBatchQueue.append(
UploadBatch(id: UUID(), indices: indices, totalCount: indices.count)
)
notifyStateChange()
}
func deleteImage(at index: Int) {
guard index >= 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(&copy)
return copy.indices.isEmpty ? nil : copy
}
}
private func notifyStateChange() {
onStateChange?()
}
}