Files
suixinkan_ios_new/suixinkan/Features/ScenicPermission/ViewModels/ScenicApplicationViewModel.swift
汉秋 26f4d0e671 Migrate from iOS 17 Observation to iOS 16-compatible Combine architecture.
Lower deployment target to iOS 16, replace @Observable with ObservableObject, add navigation and UI compatibility shims, and include login smoke UI tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 10:16:35 +08:00

317 lines
12 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
//
// Created by Codex on 2026/6/23.
//
import Foundation
import Combine
/// 稿
struct ScenicApplicationImageDraft: Equatable, Identifiable {
let id: UUID
let data: Data
let fileName: String
var uploadedURL: String?
var uploading: Bool
var progress: Int
/// 稿
init(id: UUID = UUID(), data: Data, fileName: String, uploadedURL: String? = nil, uploading: Bool = false, progress: Int = 0) {
self.id = id
self.data = data
self.fileName = fileName
self.uploadedURL = uploadedURL
self.uploading = uploading
self.progress = progress
}
}
@MainActor
/// ViewModel
final class ScenicApplicationViewModel: ObservableObject {
@Published var scenicName = ""
@Published var imageURLs = ""
@Published var localImages: [ScenicApplicationImageDraft] = []
@Published var selectedProvince = ""
@Published var selectedCity = ""
@Published var coopType = 1
@Published var remark = ""
@Published var agreed = false
@Published var submitting = false
@Published var message: String?
@Published var provinces: [ScenicAreaNode] = []
@Published var cities: [ScenicAreaNode] = []
@Published var pending: ScenicApplicationPendingResponse?
@Published var loading = false
@Published var loadFailed = false
@Published var loadFailureReason: String?
@Published var pendingLoadFailed = false
@Published var pendingLoadFailureReason: String?
///
var isReadOnly: Bool {
pending?.status == 1
}
///
var canSubmit: Bool {
!isReadOnly && !submitting && validationMessage == nil
}
///
var isSubmitLocked: Bool {
isReadOnly || submitting
}
///
var uploadPlaceholderCount: Int {
remoteImageURLs.count + localImages.count
}
/// URL
var remoteImageURLs: [String] {
imageURLs
.split(whereSeparator: \.isNewline)
.flatMap { $0.split(separator: ",") }
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
///
func loadInitial(api: any ScenicPermissionServing) async {
loading = true
loadFailed = false
loadFailureReason = nil
defer { loading = false }
do {
provinces = try await api.areas()
} catch {
let reason = "地区数据加载失败:\(error.localizedDescription)"
message = reason
loadFailed = true
loadFailureReason = reason
provinces = []
cities = []
clearPendingApplication()
return
}
await reloadPending(api: api)
}
///
func reloadPending(api: any ScenicPermissionServing) async {
pendingLoadFailed = false
pendingLoadFailureReason = nil
do {
let pendingList = try await api.scenicApplicationPendingAll().items
if let first = pendingList.first(where: { $0.status == 1 || $0.status == 3 || $0.status == 9 }) {
applyPending(first)
} else {
clearPendingApplication()
}
} catch {
clearPendingApplication()
pendingLoadFailed = true
pendingLoadFailureReason = error.localizedDescription
}
}
///
func onProvinceChange() {
let oldCity = selectedCity
cities = provinces.first(where: { $0.name == selectedProvince })?.children ?? []
if !cities.contains(where: { $0.name == oldCity }) {
selectedCity = ""
}
}
///
func addLocalImage(data: Data, fileName: String) {
guard uploadPlaceholderCount < 20 else {
message = "最多上传20张景区图片"
return
}
localImages.append(ScenicApplicationImageDraft(data: data, fileName: fileName))
}
///
func removeRemoteImage(_ url: String) {
imageURLs = remoteImageURLs.filter { $0 != url }.joined(separator: "\n")
}
///
func removeLocalImage(id: UUID) {
localImages.removeAll { $0.id == id }
}
/// OSS
func submit(api: any ScenicPermissionServing, uploader: any OSSUploadServing) async {
if let validationMessage {
message = validationMessage
return
}
guard !isSubmitLocked else { return }
submitting = true
defer { submitting = false }
do {
let uploaded = try await uploadLocalImagesIfNeeded(uploader: uploader)
let urls = remoteImageURLs + uploaded
if !urls.isEmpty {
let remotePlaceholders = remoteImageURLs.map { makeUploadPlaceholder(from: $0, fileSize: 0) }
let localPlaceholders = localImages.compactMap { item -> ScenicApplicationUploadPlaceholder? in
guard let uploadedURL = item.uploadedURL else { return nil }
return makeUploadPlaceholder(from: uploadedURL, fileName: item.fileName, fileSize: Int64(item.data.count))
}
try await api.scenicApplicationUploadPlaceholder(remotePlaceholders + localPlaceholders)
}
try await api.scenicSubmit(
ScenicApplicationSubmitRequest(
scenicName: trimmedNilIfEmpty(scenicName),
scenicImages: urls.isEmpty ? nil : urls,
scenicProvince: selectedProvince,
scenicCity: selectedCity,
coopType: coopType,
remark: remark,
scenicId: pending?.scenicId ?? 0
)
)
await reloadPending(api: api)
message = "提交成功"
} catch {
message = error.localizedDescription
}
}
///
var validationMessage: String? {
if scenicName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return "请输入景区名称"
}
if uploadPlaceholderCount == 0 {
return "请至少上传一张景区图片"
}
if selectedProvince.isEmpty {
return "请选择省份"
}
if selectedCity.isEmpty {
return "请选择城市"
}
if !agreed {
return "请先阅读并同意用户须知与隐私政策"
}
return nil
}
/// URL
private func uploadLocalImagesIfNeeded(uploader: any OSSUploadServing) async throws -> [String] {
var urls: [String] = []
for index in localImages.indices {
if let uploadedURL = localImages[index].uploadedURL {
urls.append(uploadedURL)
continue
}
localImages[index].uploading = true
localImages[index].progress = max(localImages[index].progress, 1)
do {
let uploadedURL = try await uploader.uploadScenicApplicationImage(
data: localImages[index].data,
fileName: localImages[index].fileName,
scenicId: pending?.scenicId ?? 0
) { [weak self] progress in
Task { @MainActor in
guard let self, self.localImages.indices.contains(index) else { return }
self.localImages[index].progress = progress
}
}
localImages[index].uploadedURL = uploadedURL
localImages[index].uploading = false
localImages[index].progress = 100
urls.append(uploadedURL)
} catch {
localImages[index].uploading = false
localImages[index].progress = 0
throw error
}
}
return urls
}
/// 使
private func applyPending(_ item: ScenicApplicationPendingResponse) {
pending = item
scenicName = item.scenicName
imageURLs = item.scenicImages
.split(separator: ",")
.map(String.init)
.joined(separator: "\n")
selectedProvince = item.scenicProvince
selectedCity = item.scenicCity
coopType = item.coopType
remark = item.remark ?? ""
agreed = true
localImages = []
onProvinceChange()
}
///
private func clearPendingApplication() {
pending = nil
scenicName = ""
imageURLs = ""
localImages = []
selectedProvince = ""
selectedCity = ""
cities = []
coopType = 1
remark = ""
agreed = false
}
///
private func makeUploadPlaceholder(from url: String, fileName: String? = nil, fileSize: Int64) -> ScenicApplicationUploadPlaceholder {
ScenicApplicationUploadPlaceholder(
fileName: fileName ?? ScenicUploadPlaceholderPolicy.fileName(from: url, fallbackPrefix: "scenic_upload"),
fileType: ScenicUploadPlaceholderPolicy.fileType(from: url),
fileSize: fileSize
)
}
/// nil
private func trimmedNilIfEmpty(_ value: String) -> String? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
/// URL
enum ScenicUploadPlaceholderPolicy {
/// URL 使
static func fileName(from rawValue: String, fallbackPrefix: String, uuid: UUID = UUID()) -> String {
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
let stripped = trimmed
.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first
.map(String.init)?
.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first
.map(String.init) ?? trimmed
let urlPathName = URL(string: trimmed)?.lastPathComponent
let pathName = (stripped as NSString).lastPathComponent
let decodedName = (urlPathName?.isEmpty == false ? urlPathName : pathName)?.removingPercentEncoding
let fileName = decodedName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !fileName.isEmpty, fileName != "/" else {
return "\(fallbackPrefix)_\(uuid.uuidString.prefix(8))"
}
return fileName
}
///
static func fileType(from rawValue: String) -> String {
let fileName = fileName(from: rawValue, fallbackPrefix: "upload")
let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased()
if ["mp4", "mov", "mkv", "avi"].contains(ext) { return "video" }
if ["pdf", "doc", "docx", "xls", "xlsx"].contains(ext) { return "file" }
return "image"
}
}