Add ScenicPermission module and AMap CocoaPods with simulator support.
Integrate scenic selection and permission flows into home routing, add AMap SDK for device builds while keeping arm64 simulators compilable via Podfile post_install hooks. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,317 @@
|
||||
//
|
||||
// ScenicApplicationViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 景区申请本地图片草稿实体,表示尚未或正在上传的图片。
|
||||
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
|
||||
@Observable
|
||||
/// 景区申请 ViewModel,负责新增景区申请表单、图片上传和待审核记录回填。
|
||||
final class ScenicApplicationViewModel {
|
||||
var scenicName = ""
|
||||
var imageURLs = ""
|
||||
var localImages: [ScenicApplicationImageDraft] = []
|
||||
var selectedProvince = ""
|
||||
var selectedCity = ""
|
||||
var coopType = 1
|
||||
var remark = ""
|
||||
var agreed = false
|
||||
var submitting = false
|
||||
var message: String?
|
||||
var provinces: [ScenicAreaNode] = []
|
||||
var cities: [ScenicAreaNode] = []
|
||||
var pending: ScenicApplicationPendingResponse?
|
||||
var loading = false
|
||||
var loadFailed = false
|
||||
var loadFailureReason: String?
|
||||
var pendingLoadFailed = false
|
||||
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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user