新增订单长尾流程,并迁移排队、消息、结算与审核模块
将定金订单、历史拍摄与多行程上传接入 Orders,并以真实页面替换首页排队管理、消息中心、景区结算与提现审核占位入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,610 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user