317 lines
12 KiB
Swift
317 lines
12 KiB
Swift
//
|
||
// ScenicApplicationViewModel.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/23.
|
||
//
|
||
|
||
import Foundation
|
||
|
||
/// 景区申请本地图片草稿实体,表示尚未或正在上传的图片。
|
||
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 {
|
||
var onChange: (() -> Void)?
|
||
var scenicName = "" { didSet { onChange?() } }
|
||
var imageURLs = "" { didSet { onChange?() } }
|
||
var localImages: [ScenicApplicationImageDraft] = [] { didSet { onChange?() } }
|
||
var selectedProvince = "" { didSet { onChange?() } }
|
||
var selectedCity = "" { didSet { onChange?() } }
|
||
var coopType = 1 { didSet { onChange?() } }
|
||
var remark = "" { didSet { onChange?() } }
|
||
var agreed = false { didSet { onChange?() } }
|
||
var submitting = false { didSet { onChange?() } }
|
||
var message: String? { didSet { onChange?() } }
|
||
var provinces: [ScenicAreaNode] = [] { didSet { onChange?() } }
|
||
var cities: [ScenicAreaNode] = [] { didSet { onChange?() } }
|
||
var pending: ScenicApplicationPendingResponse? { didSet { onChange?() } }
|
||
var loading = false { didSet { onChange?() } }
|
||
var loadFailed = false { didSet { onChange?() } }
|
||
var loadFailureReason: String? { didSet { onChange?() } }
|
||
var pendingLoadFailed = false { didSet { onChange?() } }
|
||
var pendingLoadFailureReason: String? { didSet { onChange?() } }
|
||
|
||
/// 返回当前申请是否只读;审核中的申请不可再次编辑提交。
|
||
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"
|
||
}
|
||
}
|