// // 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" } }