重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
444 lines
15 KiB
Swift
444 lines
15 KiB
Swift
//
|
|
// ScenicApplicationViewModel.swift
|
|
// suixinkan
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// 景区申请图片上传协议,便于单元测试注入 mock。
|
|
@MainActor
|
|
protocol ScenicApplicationImageUploading: AnyObject {
|
|
func uploadScenicApplyImage(
|
|
data: Data,
|
|
fileName: String,
|
|
scenicId: Int,
|
|
onProgress: @escaping @MainActor (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 progressBatch = batch
|
|
let url = try await uploader.uploadScenicApplyImage(
|
|
data: data,
|
|
fileName: image.fileName,
|
|
scenicId: scenicId,
|
|
onProgress: { [weak self] progress in
|
|
self?.handleUploadProgress(
|
|
index: index,
|
|
batch: progressBatch,
|
|
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?()
|
|
}
|
|
}
|