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>
605 lines
20 KiB
Swift
605 lines
20 KiB
Swift
//
|
||
// OrderLongTailViewModels.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/25.
|
||
//
|
||
|
||
import Foundation
|
||
import Combine
|
||
|
||
@MainActor
|
||
/// 押金订单列表 ViewModel,负责分页、核销、退款和操作后刷新。
|
||
final class DepositOrderListViewModel: ObservableObject {
|
||
@Published private(set) var orders: [DepositOrderListItem] = []
|
||
@Published private(set) var total = 0
|
||
@Published private(set) var page = 1
|
||
@Published private(set) var hasMore = false
|
||
@Published private(set) var loading = false
|
||
@Published private(set) var loadingMore = false
|
||
@Published private(set) var operatingOrderNumber: String?
|
||
@Published 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
|
||
/// 押金订单详情 ViewModel,负责按门店和订单号加载详情。
|
||
final class DepositOrderDetailViewModel: ObservableObject {
|
||
@Published private(set) var detail: StoreOrderDetailResponse?
|
||
@Published private(set) var loading = false
|
||
@Published 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
|
||
/// 押金拍摄信息 ViewModel,负责加载单个打卡点的媒体和评分。
|
||
final class DepositOrderShootingInfoViewModel: ObservableObject {
|
||
@Published private(set) var detail: StoreOrderShootingDetailResponse?
|
||
@Published private(set) var loading = false
|
||
@Published 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
|
||
/// 普通订单退款 ViewModel,负责退款入口判断、金额校验和提交保护。
|
||
final class OrderRefundViewModel: ObservableObject {
|
||
@Published var mode: OrderRefundMode = .full
|
||
@Published var amount = ""
|
||
@Published var reason = ""
|
||
@Published private(set) var submitting = false
|
||
@Published 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
|
||
/// 历史拍摄 ViewModel,负责按订单号加载多点位历史拍摄媒体。
|
||
final class HistoricalShootingInfoViewModel: ObservableObject {
|
||
@Published private(set) var projectName = ""
|
||
@Published private(set) var projectTypeName = ""
|
||
@Published private(set) var spots: [PhotogSpotItem] = []
|
||
@Published private(set) var loading = false
|
||
@Published 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
|
||
/// 多点旅拍任务上传 ViewModel,负责订单号、打卡点、云盘附件、本地附件和素材提交。
|
||
final class MultiTravelTaskUploadViewModel: ObservableObject {
|
||
@Published var orderNumber = ""
|
||
@Published var selectedSpotId: Int?
|
||
@Published private(set) var spots: [MultiTravelVerifiedScenicSpotItem] = []
|
||
@Published var selectedCloudFiles: [TaskCloudSelectionItem] = []
|
||
@Published var selectedLocalFiles: [TaskLocalUploadItem] = []
|
||
@Published private(set) var isLoadingSpots = false
|
||
@Published private(set) var isSubmitting = false
|
||
@Published private(set) var didSubmitSuccessfully = false
|
||
@Published var errorMessage: String?
|
||
|
||
@Published 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
|
||
}
|
||
}
|