将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
263 lines
8.2 KiB
Swift
263 lines
8.2 KiB
Swift
//
|
||
// ScenicSettlementViewModels.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/25.
|
||
//
|
||
|
||
import Foundation
|
||
import Combine
|
||
import SwiftUI
|
||
|
||
@MainActor
|
||
/// 景区结算申请 ViewModel,管理可申请景区、多选、金额备注和提交。
|
||
final class ScenicSettlementViewModel: ObservableObject {
|
||
@Published var existingScenics: [BusinessScope] = []
|
||
@Published var options: [ScenicSettlementOption] = []
|
||
@Published var amountText = ""
|
||
@Published var remarkText = ""
|
||
@Published var isLoading = false
|
||
@Published var isSubmitting = false
|
||
@Published var loadFailed = false
|
||
@Published var loadFailureReason: String?
|
||
@Published var message: String?
|
||
|
||
@Published private var selectedIds = Set<Int>()
|
||
|
||
/// 已选择景区数量。
|
||
var selectedCount: Int {
|
||
selectedIds.count
|
||
}
|
||
|
||
/// 当前选择的景区 ID,供测试和提交使用。
|
||
var selectedScenicIds: Set<Int> {
|
||
selectedIds
|
||
}
|
||
|
||
/// 金额校验状态。
|
||
var amountValidation: ScenicSettlementAmountValidation {
|
||
let trimmed = amountText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if trimmed.isEmpty { return .empty }
|
||
guard trimmed.range(of: #"^\d+(\.\d{1,2})?$"#, options: .regularExpression) != nil else {
|
||
return .invalidFormat
|
||
}
|
||
guard let decimal = Decimal(string: trimmed), decimal > 0 else {
|
||
return .notPositive
|
||
}
|
||
return .valid
|
||
}
|
||
|
||
/// 是否允许提交结算申请。
|
||
var canSubmit: Bool {
|
||
!isSubmitting && !selectedIds.isEmpty && amountValidation == .valid
|
||
}
|
||
|
||
/// 加载已开通景区和可申请结算景区。
|
||
func load(api: any ScenicPermissionServing, existingScenics: [BusinessScope]) async {
|
||
isLoading = true
|
||
loadFailed = false
|
||
loadFailureReason = nil
|
||
self.existingScenics = existingScenics
|
||
defer { isLoading = false }
|
||
|
||
do {
|
||
let all = try await api.scenicListAll().list
|
||
let existingIds = Set(existingScenics.map(\.id))
|
||
options = all
|
||
.filter { !existingIds.contains($0.id) }
|
||
.map { ScenicSettlementOption(id: $0.id, name: $0.name, selected: selectedIds.contains($0.id)) }
|
||
} catch {
|
||
options = []
|
||
selectedIds.removeAll()
|
||
loadFailed = true
|
||
loadFailureReason = error.localizedDescription
|
||
message = "可申请景区加载失败,请重试"
|
||
}
|
||
}
|
||
|
||
/// 切换一个景区的结算申请选择状态。
|
||
func toggleScenic(id: Int) {
|
||
guard let index = options.firstIndex(where: { $0.id == id }) else { return }
|
||
options[index].selected.toggle()
|
||
if options[index].selected {
|
||
selectedIds.insert(id)
|
||
} else {
|
||
selectedIds.remove(id)
|
||
}
|
||
}
|
||
|
||
/// 提交结算申请,多景区按 ID 升序逐条提交。
|
||
func submit(api: any ScenicSettlementServing) async -> Bool {
|
||
guard !isSubmitting else { return false }
|
||
guard canSubmit else {
|
||
message = validationMessage
|
||
return false
|
||
}
|
||
|
||
isSubmitting = true
|
||
defer { isSubmitting = false }
|
||
|
||
let amount = normalizedAmount
|
||
let remark = remarkText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
|
||
do {
|
||
for scenicId in selectedIds.sorted() {
|
||
try await api.scenicSettlementSubmit(
|
||
ScenicSettlementSubmitRequest(
|
||
scenicId: scenicId,
|
||
applyAmount: amount,
|
||
applyRemark: remark
|
||
)
|
||
)
|
||
}
|
||
selectedIds.removeAll()
|
||
options = options.map { option in
|
||
ScenicSettlementOption(id: option.id, name: option.name, selected: false)
|
||
}
|
||
amountText = ""
|
||
remarkText = ""
|
||
message = "提交成功,等待审核"
|
||
return true
|
||
} catch {
|
||
message = error.localizedDescription
|
||
return false
|
||
}
|
||
}
|
||
|
||
private var normalizedAmount: String {
|
||
let decimal = Decimal(string: amountText.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
|
||
return String(format: "%.2f", NSDecimalNumber(decimal: decimal).doubleValue)
|
||
}
|
||
|
||
private var validationMessage: String {
|
||
if selectedIds.isEmpty { return "请先选择景区" }
|
||
switch amountValidation {
|
||
case .empty:
|
||
return "请填写结算金额"
|
||
case .invalidFormat:
|
||
return "金额格式错误,最多支持两位小数"
|
||
case .notPositive:
|
||
return "金额需大于 0"
|
||
case .valid:
|
||
return ""
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 景区结算金额校验结果。
|
||
enum ScenicSettlementAmountValidation: Equatable {
|
||
case empty
|
||
case invalidFormat
|
||
case notPositive
|
||
case valid
|
||
|
||
/// 对应的状态提示文案。
|
||
var badgeText: String {
|
||
switch self {
|
||
case .empty: "待填写金额"
|
||
case .invalidFormat: "金额格式错误"
|
||
case .notPositive: "金额需大于 0"
|
||
case .valid: "金额有效"
|
||
}
|
||
}
|
||
|
||
/// 对应的状态颜色。
|
||
var badgeColor: Color {
|
||
switch self {
|
||
case .empty, .notPositive:
|
||
return AppDesign.warning
|
||
case .invalidFormat:
|
||
return Color(hex: 0xDC2626)
|
||
case .valid:
|
||
return AppDesign.success
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
/// 景区结算审核 ViewModel,聚合景区申请和权限申请审核记录。
|
||
final class ScenicSettlementReviewViewModel: ObservableObject {
|
||
@Published var scenicApplications: [ScenicApplicationPendingResponse] = []
|
||
@Published var roleApplications: [RoleApplyPendingResponse] = []
|
||
@Published var isLoading = false
|
||
@Published var message: String?
|
||
@Published var loadFailedAll = false
|
||
@Published var scenicLoadFailed = false
|
||
@Published var roleLoadFailed = false
|
||
|
||
/// 待审核记录数量。
|
||
var pendingCount: Int {
|
||
scenicApplications.filter { $0.status == 1 }.count + roleApplications.filter { $0.status == 1 }.count
|
||
}
|
||
|
||
/// 加载审核记录,单通道失败时保留另一通道数据。
|
||
func load(api: any ScenicPermissionServing) async {
|
||
isLoading = true
|
||
loadFailedAll = false
|
||
scenicLoadFailed = false
|
||
roleLoadFailed = false
|
||
message = nil
|
||
defer { isLoading = false }
|
||
|
||
var errors: [String] = []
|
||
var scenicLoaded = false
|
||
var roleLoaded = false
|
||
|
||
do {
|
||
scenicApplications = try await api.scenicApplicationPendingAll().items
|
||
scenicLoaded = true
|
||
} catch {
|
||
scenicApplications = []
|
||
scenicLoadFailed = true
|
||
errors.append("景区申请记录加载失败")
|
||
}
|
||
|
||
do {
|
||
roleApplications = try await api.roleApplyAll()
|
||
roleLoaded = true
|
||
} catch {
|
||
roleApplications = []
|
||
roleLoadFailed = true
|
||
errors.append("权限申请记录加载失败")
|
||
}
|
||
|
||
loadFailedAll = !scenicLoaded && !roleLoaded
|
||
if !errors.isEmpty {
|
||
message = errors.joined(separator: ";")
|
||
}
|
||
}
|
||
|
||
/// 审核状态文案。
|
||
func statusText(_ status: Int, fallback: String = "") -> String {
|
||
let trimmed = fallback.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if !trimmed.isEmpty { return trimmed }
|
||
switch status {
|
||
case 1: return "待审核"
|
||
case 2: return "已通过"
|
||
case 3: return "已驳回"
|
||
case 9: return "已取消"
|
||
default: return "未知"
|
||
}
|
||
}
|
||
|
||
/// 审核状态图标。
|
||
func statusIcon(_ status: Int) -> String {
|
||
switch status {
|
||
case 2: return "checkmark.circle.fill"
|
||
case 3: return "xmark.octagon.fill"
|
||
case 1: return "clock.badge.exclamationmark.fill"
|
||
default: return "questionmark.circle.fill"
|
||
}
|
||
}
|
||
|
||
/// 审核状态颜色。
|
||
func statusColor(_ status: Int) -> Color {
|
||
switch status {
|
||
case 2: return AppDesign.success
|
||
case 3: return Color(hex: 0xDC2626)
|
||
case 1: return AppDesign.warning
|
||
default: return AppDesign.textSecondary
|
||
}
|
||
}
|
||
}
|