// // 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() /// 已选择景区数量。 var selectedCount: Int { selectedIds.count } /// 当前选择的景区 ID,供测试和提交使用。 var selectedScenicIds: Set { 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 } } }