// // OrderLongTailViewModels.swift // suixinkan // // Created by Codex on 2026/6/25. // import Foundation import Observation @MainActor @Observable /// 押金订单列表 ViewModel,负责分页、核销、退款和操作后刷新。 final class DepositOrderListViewModel { private(set) var orders: [DepositOrderListItem] = [] private(set) var total = 0 private(set) var page = 1 private(set) var hasMore = false private(set) var loading = false private(set) var loadingMore = false private(set) var operatingOrderNumber: String? var errorMessage: String? private let pageSize = 10 /// 加载押金订单列表,缺少景区时清空旧数据且不请求接口。 func reload(api: OrderServing, scenicId: Int?, reset: Bool) async { guard let scenicId, scenicId > 0 else { resetState() return } if reset { page = 1 total = 0 orders = [] loading = true } else { guard !loadingMore, hasMore else { return } loadingMore = true } defer { loading = false loadingMore = false } do { let result = try await api.depositOrderList(scenicId: scenicId, page: page, pageSize: pageSize) total = result.total if page == 1 { orders = result.list } else { orders.append(contentsOf: result.list) } page += 1 hasMore = orders.count < result.total && !result.list.isEmpty errorMessage = nil } catch { if reset { orders = [] total = 0 page = 1 hasMore = false } errorMessage = error.localizedDescription } } /// 核销押金订单,成功后刷新第一页。 func writeOff(api: OrderServing, scenicId: Int?, orderNumber: String) async -> Bool { let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalized.isEmpty, operatingOrderNumber == nil else { return false } guard let scenicId, scenicId > 0 else { errorMessage = "缺少景区上下文" return false } operatingOrderNumber = normalized defer { operatingOrderNumber = nil } do { try await api.depositOrderWriteOff(orderNumber: normalized) await reload(api: api, scenicId: scenicId, reset: true) return true } catch { errorMessage = error.localizedDescription return false } } /// 申请押金订单退款,原因必填,成功后刷新第一页。 func refund(api: OrderServing, scenicId: Int?, orderNumber: String, reason: String) async -> Bool { let normalizedOrderNumber = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedOrderNumber.isEmpty, operatingOrderNumber == nil else { return false } guard !normalizedReason.isEmpty else { errorMessage = "请填写退款原因" return false } guard let scenicId, scenicId > 0 else { errorMessage = "缺少景区上下文" return false } operatingOrderNumber = normalizedOrderNumber defer { operatingOrderNumber = nil } do { try await api.depositOrderRefund(orderNumber: normalizedOrderNumber, refundReason: normalizedReason) await reload(api: api, scenicId: scenicId, reset: true) return true } catch { errorMessage = error.localizedDescription return false } } /// 清理错误文案。 func clearError() { errorMessage = nil } private func resetState() { orders = [] total = 0 page = 1 hasMore = false loading = false loadingMore = false operatingOrderNumber = nil errorMessage = nil } } @MainActor @Observable /// 押金订单详情 ViewModel,负责按门店和订单号加载详情。 final class DepositOrderDetailViewModel { private(set) var detail: StoreOrderDetailResponse? private(set) var loading = false var errorMessage: String? /// 加载押金订单详情,缺少门店或订单号时不请求接口。 func load(api: OrderServing, storeId: Int?, orderNumber: String) async { let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) guard let storeId, storeId > 0 else { reset(message: "当前账号缺少门店信息") return } guard !normalized.isEmpty else { reset(message: "订单号不能为空") return } detail = nil loading = true defer { loading = false } do { detail = try await api.storeOrderDetail(storeId: storeId, orderNumber: normalized) errorMessage = nil } catch { detail = nil errorMessage = error.localizedDescription } } /// 清理错误文案。 func clearError() { errorMessage = nil } private func reset(message: String) { detail = nil loading = false errorMessage = message } } @MainActor @Observable /// 押金拍摄信息 ViewModel,负责加载单个打卡点的媒体和评分。 final class DepositOrderShootingInfoViewModel { private(set) var detail: StoreOrderShootingDetailResponse? private(set) var loading = false var errorMessage: String? /// 加载押金订单拍摄信息,缺少门店或订单号时不请求接口。 func load(api: OrderServing, storeId: Int?, orderNumber: String, scenicSpotId: Int, photogUid: Int) async { let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) guard let storeId, storeId > 0 else { reset(message: "当前账号缺少门店信息") return } guard !normalized.isEmpty else { reset(message: "订单号不能为空") return } detail = nil loading = true defer { loading = false } do { detail = try await api.storeOrderShootingDetail( storeId: storeId, orderNumber: normalized, scenicSpotId: scenicSpotId, photogUid: photogUid ) errorMessage = nil } catch { detail = nil errorMessage = error.localizedDescription } } /// 清理错误文案。 func clearError() { errorMessage = nil } private func reset(message: String) { detail = nil loading = false errorMessage = message } } @MainActor @Observable /// 普通订单退款 ViewModel,负责退款入口判断、金额校验和提交保护。 final class OrderRefundViewModel { var mode: OrderRefundMode = .full var amount = "" var reason = "" private(set) var submitting = false var errorMessage: String? /// 判断订单是否允许展示普通退款入口。 func canRefund(_ item: OrderEntity) -> Bool { (item.orderStatus == 18 || item.orderStatus == 30) && item.orderType != 19 && availableAmount(for: item) > 0 } /// 打开退款表单时初始化默认值。 func begin(item: OrderEntity) { mode = .full amount = availableAmountText(for: item) reason = "" errorMessage = nil } /// 提交普通订单退款。 func submit(api: OrderServing, item: OrderEntity) async -> Bool { guard !submitting else { return false } guard canRefund(item) else { errorMessage = "当前订单不可退款" return false } let normalizedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedReason.isEmpty else { errorMessage = "请输入退款原因" return false } let refundAmount: String switch mode { case .full: refundAmount = availableAmountText(for: item) case .partial: guard let normalized = Self.normalizedMoney(amount) else { errorMessage = "请输入有效的退款金额" return false } guard (Decimal(string: normalized) ?? 0) <= availableAmount(for: item) else { errorMessage = "退款金额不能大于可退金额" return false } refundAmount = normalized } submitting = true errorMessage = nil defer { submitting = false } do { try await api.orderRefund( orderNumber: item.orderNumber, refundType: mode, refundAmount: refundAmount, refundReason: normalizedReason ) return true } catch { errorMessage = error.localizedDescription return false } } /// 计算普通订单可退金额。 func availableAmount(for item: OrderEntity) -> Decimal { let paid = Self.decimalValue(item.actualPayAmount) if paid > 0 { return paid } return Self.decimalValue(item.actualRefundAmount.isEmpty ? item.refundAmount : item.actualRefundAmount) } /// 返回可退金额展示和提交文案。 func availableAmountText(for item: OrderEntity) -> String { Self.moneyText(availableAmount(for: item)) } /// 将输入金额规范成两位小数,空值、非法值和超过两位小数返回 nil。 static func normalizedMoney(_ rawValue: String) -> String? { let text = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: ",", with: "") guard !text.isEmpty else { return nil } let pattern = #"^\d+(\.\d{1,2})?$"# guard text.range(of: pattern, options: .regularExpression) != nil, let value = Decimal(string: text), value > 0 else { return nil } return moneyText(value) } private static func decimalValue(_ text: String) -> Decimal { Decimal(string: text.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: ",", with: "")) ?? 0 } private static func moneyText(_ value: Decimal) -> String { let number = NSDecimalNumber(decimal: value) let handler = NSDecimalNumberHandler( roundingMode: .plain, scale: 2, raiseOnExactness: false, raiseOnOverflow: false, raiseOnUnderflow: false, raiseOnDivideByZero: false ) return String(format: "%.2f", number.rounding(accordingToBehavior: handler).doubleValue) } } @MainActor @Observable /// 历史拍摄 ViewModel,负责按订单号加载多点位历史拍摄媒体。 final class HistoricalShootingInfoViewModel { private(set) var projectName = "" private(set) var projectTypeName = "" private(set) var spots: [PhotogSpotItem] = [] private(set) var loading = false var errorMessage: String? /// 加载历史拍摄信息,订单号为空时清空旧数据且不请求接口。 func load(api: OrderServing, orderNumber: String) async { let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalized.isEmpty else { reset(message: "订单号不能为空") return } loading = true defer { loading = false } do { let result = try await api.multiTravelShootHistory(orderNumber: normalized) projectName = result.projectName projectTypeName = result.projectTypeName spots = result.photogSpotList errorMessage = nil } catch { reset(message: error.localizedDescription) } } /// 清理错误文案。 func clearError() { errorMessage = nil } private func reset(message: String) { projectName = "" projectTypeName = "" spots = [] loading = false errorMessage = message } } @MainActor @Observable /// 多点旅拍任务上传 ViewModel,负责订单号、打卡点、云盘附件、本地附件和素材提交。 final class MultiTravelTaskUploadViewModel { var orderNumber = "" var selectedSpotId: Int? private(set) var spots: [MultiTravelVerifiedScenicSpotItem] = [] var selectedCloudFiles: [TaskCloudSelectionItem] = [] var selectedLocalFiles: [TaskLocalUploadItem] = [] private(set) var isLoadingSpots = false private(set) var isSubmitting = false private(set) var didSubmitSuccessfully = false var errorMessage: String? private var loadedSpotOrderNumber = "" /// 当前选中打卡点展示名。 var selectedSpotName: String { spots.first(where: { $0.id == selectedSpotId })?.name ?? "请选择打卡点" } /// 判断提交按钮是否满足基础启用条件。 var canSubmit: Bool { !isSubmitting && selectedSpotId != nil && (!selectedCloudFiles.isEmpty || !selectedLocalFiles.isEmpty) && !selectedLocalFiles.contains(where: \.isUploading) } /// 初始化任务上传表单订单号。 init(initialOrderNumber: String = "") { orderNumber = initialOrderNumber } /// 加载当前订单已核销打卡点,订单号为空时不请求接口。 func loadSpots(api: OrderServing) async { let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalized.isEmpty else { resetSpotSelection() return } isLoadingSpots = true defer { isLoadingSpots = false } do { let result = try await api.multiTravelVerifiedScenicSpotList(orderNumber: normalized) spots = result loadedSpotOrderNumber = normalized if !spots.contains(where: { $0.id == selectedSpotId }) { selectedSpotId = spots.first?.id } errorMessage = nil } catch { resetSpotSelection() errorMessage = error.localizedDescription } } /// 切换选中的打卡点。 func selectSpot(id: Int?) { selectedSpotId = id } /// 合并云盘附件,按文件 ID 去重。 func mergeCloudFiles(_ files: [TaskCloudSelectionItem]) { var next = selectedCloudFiles for file in files where !next.contains(where: { $0.id == file.id }) { next.append(file) } selectedCloudFiles = next } /// 移除指定云盘附件。 func removeCloudFile(id: Int) { selectedCloudFiles.removeAll { $0.id == id } } /// 添加本地图片或视频附件。 func addLocalFile(data: Data, fileName: String) { selectedLocalFiles.append( TaskLocalUploadItem( id: UUID(), data: data, fileName: fileName, fileType: Self.fileType(for: fileName), remark: "", uploadedURL: nil, progress: 0, errorMessage: nil ) ) } /// 移除指定本地附件。 func removeLocalFile(id: UUID) { selectedLocalFiles.removeAll { $0.id == id } } /// 提交多点旅拍任务素材;本地附件先上传 OSS,再提交最终 URL。 @discardableResult func submit(api: OrderServing, uploadService: any OSSUploadServing, scenicId: Int?) async -> Bool { guard !isSubmitting else { return false } guard let requestContext = validateBeforeSubmit(scenicId: scenicId) else { return false } isSubmitting = true didSubmitSuccessfully = false errorMessage = nil defer { isSubmitting = false } do { let uploadFiles = try await uploadLocalFiles(uploadService: uploadService, scenicId: requestContext.scenicId) try await api.multiTravelUploadMaterial( MultiTravelUploadMaterialRequest( orderNumber: requestContext.orderNumber, scenicSpotId: requestContext.scenicSpotId, cloudFile: selectedCloudFiles.map { MultiTravelCloudFileItem(fileId: $0.id) }, uploadFile: uploadFiles ) ) didSubmitSuccessfully = true return true } catch { errorMessage = error.localizedDescription return false } } /// 清理错误文案。 func clearError() { errorMessage = nil } private func validateBeforeSubmit(scenicId: Int?) -> SubmitContext? { let normalizedOrderNumber = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) guard let scenicId, scenicId > 0 else { errorMessage = "请先选择景区" return nil } guard !normalizedOrderNumber.isEmpty else { errorMessage = "请先填写订单号" return nil } guard loadedSpotOrderNumber == normalizedOrderNumber else { errorMessage = "请先刷新打卡点" return nil } guard let selectedSpotId else { errorMessage = "请选择打卡点" return nil } guard !selectedCloudFiles.isEmpty || !selectedLocalFiles.isEmpty else { errorMessage = "请至少选择一个素材文件" return nil } guard !selectedLocalFiles.contains(where: \.isUploading) else { errorMessage = "文件上传中,请稍后提交" return nil } return SubmitContext(scenicId: scenicId, orderNumber: normalizedOrderNumber, scenicSpotId: selectedSpotId) } private func uploadLocalFiles(uploadService: any OSSUploadServing, scenicId: Int) async throws -> [MultiTravelUploadFileItem] { var uploadFiles: [MultiTravelUploadFileItem] = [] for index in selectedLocalFiles.indices { if let uploadedURL = selectedLocalFiles[index].uploadedURL { uploadFiles.append(MultiTravelUploadFileItem(fileName: selectedLocalFiles[index].fileName, fileUrl: uploadedURL)) continue } selectedLocalFiles[index].errorMessage = nil let fileID = selectedLocalFiles[index].id do { let url = try await uploadService.uploadTaskFile( data: selectedLocalFiles[index].data, fileName: selectedLocalFiles[index].fileName, fileType: selectedLocalFiles[index].fileType, scenicId: scenicId, onProgress: { [weak self] progress in Task { @MainActor in self?.updateLocalFileProgress(id: fileID, progress: progress) } } ) selectedLocalFiles[index].uploadedURL = url selectedLocalFiles[index].progress = 100 uploadFiles.append(MultiTravelUploadFileItem(fileName: selectedLocalFiles[index].fileName, fileUrl: url)) } catch { selectedLocalFiles[index].progress = 0 selectedLocalFiles[index].errorMessage = error.localizedDescription throw error } } return uploadFiles } private func updateLocalFileProgress(id: UUID, progress: Int) { guard let index = selectedLocalFiles.firstIndex(where: { $0.id == id }) else { return } selectedLocalFiles[index].progress = progress } private func resetSpotSelection() { spots = [] selectedSpotId = nil loadedSpotOrderNumber = "" isLoadingSpots = false } private static func fileType(for fileName: String) -> Int { let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased() return ["mp4", "mov", "m4v", "avi"].contains(ext) ? 1 : 2 } private struct SubmitContext { let scenicId: Int let orderNumber: String let scenicSpotId: Int } }