// // WalletViewModels.swift // suixinkan // import Foundation @MainActor /// 钱包前置资料服务协议,提供提现前实名和银行卡状态检查。 protocol WalletProfileServing { /// 拉取当前登录用户的实名认证状态。 func realNameInfo() async throws -> RealNameInfoResponse /// 拉取当前登录用户的银行卡信息。 func bankCardInfo() async throws -> BankCardInfoResponse } extension ProfileAPI: WalletProfileServing {} /// 钱包首页 Tab。 enum WalletTab: Int, CaseIterable { case withdraw case transaction /// 展示标题。 var title: String { switch self { case .withdraw: "提现记录" case .transaction: "收益明细" } } } /// 钱包收益明细日期筛选。 enum WalletFilter: Int, CaseIterable { case last7Days case last30Days case last180Days /// 展示标题。 var title: String { switch self { case .last7Days: "最近7天" case .last30Days: "最近30天" case .last180Days: "最近180天" } } /// 向前取数天数。 var dayOffset: Int { switch self { case .last7Days: 7 case .last30Days: 30 case .last180Days: 180 } } } /// 钱包提现按钮检查后的导航目标。 enum WalletWithdrawDestination: Equatable { case realNameAuth case realNameAudit(RealNameInfo) case realNamePending case withdrawalSettings case withdrawalAudit(BankCardInfo) case bankCardPending case withdraw } /// 钱包首页 ViewModel,管理汇总、提现记录、收益明细、积分概览和提现前置分流。 final class WalletViewModel { private(set) var summary: WalletSummaryResponse? private(set) var selectedTab: WalletTab = .withdraw private(set) var filter: WalletFilter = .last7Days private(set) var withdrawRecords: [WalletWithdrawItem] = [] private(set) var transactionGroups: [EarningDetailGroup] = [] private(set) var totalIncomeLabel = "¥ 0.00" private(set) var totalPointsLabel = 0 private(set) var availablePoints = 0 private(set) var summaryLoading = false private(set) var withdrawLoading = false private(set) var withdrawRefreshing = false private(set) var transactionLoading = false private(set) var transactionRefreshing = false private(set) var pointsLoading = false private(set) var errorMessage: String? var onStateChange: (() -> Void)? private let staffIdProvider: () -> Int? private let calendar: Calendar private let nowProvider: () -> Date private var withdrawPage = 1 private var withdrawCanLoadMore = true private var transactionPage = 1 private var transactionCanLoadMore = true private let pageSize = 10 /// 初始化钱包首页 ViewModel。 init( staffIdProvider: @escaping () -> Int? = { Int(AppStore.shared.session.userId.trimmingCharacters(in: .whitespacesAndNewlines)) }, calendar: Calendar = Calendar(identifier: .gregorian), nowProvider: @escaping () -> Date = Date.init ) { self.staffIdProvider = staffIdProvider self.calendar = calendar self.nowProvider = nowProvider } /// 首次加载钱包首页全部首屏数据。 func loadInitial(api: any WalletPageServing) async { await refreshSummary(api: api) await refreshWithdraw(api: api) await refreshTransaction(api: api) await refreshPoints(api: api) } /// 刷新钱包金额汇总。 func refreshSummary(api: any WalletPageServing) async { summaryLoading = true errorMessage = nil notifyStateChange() do { summary = try await api.walletSummary(type: 0) } catch { errorMessage = error.localizedDescription } summaryLoading = false notifyStateChange() } /// 刷新当前可用积分。 func refreshPoints(api: any WalletPageServing) async { guard let staffId = staffIdProvider(), staffId > 0 else { return } pointsLoading = true notifyStateChange() do { let overview = try await api.pointOverview(staffId: staffId) availablePoints = overview.availablePoints } catch { errorMessage = error.localizedDescription } pointsLoading = false notifyStateChange() } /// 切换钱包 Tab。 func selectTab(_ tab: WalletTab, api: any WalletPageServing) async { guard tab != selectedTab else { return } selectedTab = tab notifyStateChange() switch tab { case .withdraw: if withdrawRecords.isEmpty { await refreshWithdraw(api: api) } case .transaction: if transactionGroups.isEmpty { await refreshTransaction(api: api) } } } /// 切换收益明细筛选。 func selectFilter(_ nextFilter: WalletFilter, api: any WalletPageServing) async { guard nextFilter != filter else { return } filter = nextFilter notifyStateChange() await refreshTransaction(api: api) } /// 刷新提现记录。 func refreshWithdraw(api: any WalletPageServing) async { await loadWithdraw(api: api, refresh: true) } /// 仅刷新当前选中的钱包 Tab。 func refreshSelectedTab(api: any WalletPageServing) async { switch selectedTab { case .withdraw: await refreshWithdraw(api: api) case .transaction: await refreshTransaction(api: api) } } /// 滚动到底部时加载更多提现记录。 func loadMoreWithdrawIfNeeded(currentIndex: Int, api: any WalletPageServing) async { guard currentIndex >= withdrawRecords.count - 2, withdrawCanLoadMore, !withdrawLoading else { return } await loadWithdraw(api: api, refresh: false) } /// 刷新收益明细。 func refreshTransaction(api: any WalletPageServing) async { await loadTransaction(api: api, refresh: true) } /// 滚动到底部时加载更多收益明细。 func loadMoreTransactionIfNeeded(currentIndex: Int, api: any WalletPageServing) async { let count = transactionEntries.count guard currentIndex >= count - 2, transactionCanLoadMore, !transactionLoading else { return } await loadTransaction(api: api, refresh: false) } /// 收益明细列表条目,将分组头和明细扁平化。 var transactionEntries: [WalletTransactionEntry] { transactionGroups.flatMap { group -> [WalletTransactionEntry] in [.header(group)] + group.items.map(WalletTransactionEntry.item) } } /// 检查提现前置状态并返回页面导航目标。 func resolveWithdrawDestination(profileAPI: any WalletProfileServing) async throws -> WalletWithdrawDestination { let realName = try await profileAPI.realNameInfo().realNameInfo guard let realName else { return .realNameAuth } switch realName.auditStatus { case 2: break case 3: return .realNameAudit(realName) default: return .realNamePending } let bankCard = try await profileAPI.bankCardInfo().bankCard guard let bankCard else { return .withdrawalSettings } switch bankCard.auditStatus { case 2: return .withdraw case 3: return .withdrawalAudit(bankCard) default: return .bankCardPending } } private func loadWithdraw(api: any WalletPageServing, refresh: Bool) async { if withdrawLoading && !refresh { return } let targetPage = refresh ? 1 : withdrawPage let previousPage = withdrawPage let previousCanLoadMore = withdrawCanLoadMore withdrawLoading = true withdrawRefreshing = refresh errorMessage = nil if refresh { withdrawCanLoadMore = true withdrawPage = 1 } notifyStateChange() do { let response = try await api.walletWithdrawList(page: targetPage, pageSize: pageSize) withdrawRecords = refresh ? response.item : withdrawRecords + response.item withdrawCanLoadMore = withdrawRecords.count < response.total withdrawPage = response.item.isEmpty ? targetPage : targetPage + 1 } catch { if refresh { withdrawPage = previousPage withdrawCanLoadMore = previousCanLoadMore } errorMessage = error.localizedDescription } withdrawLoading = false withdrawRefreshing = false notifyStateChange() } private func loadTransaction(api: any WalletPageServing, refresh: Bool) async { if transactionLoading && !refresh { return } let targetPage = refresh ? 1 : transactionPage let previousPage = transactionPage let previousCanLoadMore = transactionCanLoadMore let range = dateRange(for: filter) transactionLoading = true transactionRefreshing = refresh errorMessage = nil if refresh { transactionCanLoadMore = true transactionPage = 1 } notifyStateChange() do { let response = try await api.earningDetail( startDate: range.start, endDate: range.end, page: targetPage, pageSize: pageSize ) transactionGroups = refresh ? response.list : transactionGroups + response.list totalIncomeLabel = Self.formatAmount(response.totalAmount) totalPointsLabel = response.totalPoints let itemCount = transactionGroups.reduce(0) { $0 + $1.items.count } transactionCanLoadMore = itemCount < response.total transactionPage = response.list.isEmpty ? targetPage : targetPage + 1 } catch { if refresh { transactionPage = previousPage transactionCanLoadMore = previousCanLoadMore } errorMessage = error.localizedDescription } transactionLoading = false transactionRefreshing = false notifyStateChange() } private func dateRange(for filter: WalletFilter) -> (start: String, end: String) { let now = nowProvider() let end = calendar.startOfDay(for: now) let start = calendar.date(byAdding: .day, value: -filter.dayOffset, to: end) ?? end return (Self.dayFormatter.string(from: start), Self.dayFormatter.string(from: end)) } private func notifyStateChange() { onStateChange?() } /// 格式化人民币金额。 static func formatAmount(_ value: String?) -> String { guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return "¥ 0.00" } guard let decimal = Decimal(string: value) else { return "¥ \(value)" } let number = NSDecimalNumber(decimal: decimal) let formatter = NumberFormatter() formatter.minimumFractionDigits = 2 formatter.maximumFractionDigits = 2 formatter.roundingMode = .halfUp return "¥ \(formatter.string(from: number) ?? value)" } private static let dayFormatter: DateFormatter = { let formatter = DateFormatter() formatter.calendar = Calendar(identifier: .gregorian) formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "yyyy-MM-dd" return formatter }() } /// 钱包收益明细列表扁平条目。 enum WalletTransactionEntry: Hashable { case header(EarningDetailGroup) case item(EarningDetailItem) } /// 钱包记录状态的语义色调,由页面映射为模块内固定色值。 enum WalletStatusTone: Equatable { case info case success case warning case danger } /// 银行卡提现记录的状态展示映射,对齐 Android 结算进度和时间线可见性。 struct WalletWithdrawStatusDisplay: Equatable { let tone: WalletStatusTone let progress: Int let showsTimeline: Bool /// 根据后端结算状态创建展示数据。 init(status: Int) { switch status { case 20: tone = .success progress = 80 showsTimeline = true case 30: tone = .danger progress = 100 showsTimeline = true case 40: tone = .warning progress = 40 showsTimeline = true case 50: tone = .success progress = 100 showsTimeline = false case 60: tone = .danger progress = 0 showsTimeline = true default: tone = .info progress = status == 10 ? 10 : 100 showsTimeline = true } } } /// 积分兑现记录的状态文案、进度与时间字段映射。 struct PointWithdrawStatusDisplay: Equatable { let title: String let tone: WalletStatusTone let progress: Int let timeTitle: String /// 根据后端积分提现状态创建展示数据。 init(status: Int) { switch status { case 1: title = "提现中" tone = .warning progress = 40 timeTitle = "预计到账时间:" case 2: title = "提现成功" tone = .success progress = 0 timeTitle = "到账时间:" case 3: title = "提现失败" tone = .danger progress = 100 timeTitle = "审核时间:" default: title = "未知" tone = .info progress = 0 timeTitle = "预计到账时间:" } } } /// 提现申请 ViewModel,管理金额、短信验证码、倒计时和提交状态。 final class WithdrawViewModel { private(set) var withdrawInfo: WithdrawInfoResponse? private(set) var amount = "" private(set) var verificationCode = "" private(set) var countdown = 0 private(set) var loading = false private(set) var submitting = false private(set) var statusMessage: String? private(set) var errorMessage: String? var onStateChange: (() -> Void)? /// 加载提现申请页信息。 func load(api: any WalletPageServing) async { loading = true errorMessage = nil notifyStateChange() do { withdrawInfo = try await api.withdrawInfo() } catch { errorMessage = "获取提现信息失败:\(error.localizedDescription)" } loading = false notifyStateChange() } /// 更新提现金额。 func updateAmount(_ value: String) { amount = Self.normalizedMoneyInput(value, previous: amount) notifyStateChange() } /// 填入全部可提现金额。 func withdrawAll() { amount = withdrawInfo?.amountWithdrawable ?? "" notifyStateChange() } /// 更新短信验证码。 func updateVerificationCode(_ value: String) { verificationCode = value notifyStateChange() } /// 发送提现短信验证码。 func requestVerificationCode(api: any WalletPageServing) async { guard countdown == 0 else { return } do { try await api.withdrawSendSMS() statusMessage = "验证码发送成功" countdown = 60 } catch { errorMessage = "验证码发送失败:\(error.localizedDescription)" } notifyStateChange() } /// 倒计时减少一秒,由页面定时器驱动。 func decrementCountdown() { guard countdown > 0 else { return } countdown -= 1 notifyStateChange() } /// 提交提现申请。 func submit(api: any WalletPageServing) async -> Bool { if let validationMessage { errorMessage = validationMessage notifyStateChange() return false } submitting = true notifyStateChange() do { try await api.withdrawApply(amount: amount, smsCode: verificationCode) statusMessage = "已提交申请" submitting = false notifyStateChange() return true } catch { errorMessage = error.localizedDescription submitting = false notifyStateChange() return false } } /// 表单是否可提交。 var canSubmit: Bool { withdrawInfo != nil && !amount.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !verificationCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !submitting } /// 表单校验提示。 var validationMessage: String? { guard let withdrawInfo else { return "提现信息加载中" } guard !amount.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return "请输入提现金额" } guard let amountDecimal = Decimal(string: amount) else { return "请输入有效的金额" } let withdrawable = Decimal(string: withdrawInfo.amountWithdrawable) ?? 0 if amountDecimal > withdrawable { return "金额不能大于可提现金额" } guard !verificationCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return "请输入验证码" } return nil } /// 规范化金额输入,最多保留两位小数。 static func normalizedMoneyInput(_ value: String, previous: String) -> String { var result = "" var hasDot = false var decimalCount = 0 for character in value { if character.isNumber { if hasDot { guard decimalCount < 2 else { continue } decimalCount += 1 } result.append(character) } else if character == ".", !hasDot { hasDot = true if result.isEmpty { result = "0" } result.append(character) } } while result.count > 1, result.first == "0", result.dropFirst().first?.isNumber == true { result.removeFirst() } return result } private func notifyStateChange() { onStateChange?() } } /// 积分兑现 ViewModel,管理积分概览、积分提现吗申请和记录分页。 final class PointsRedemptionViewModel { private(set) var overview: PointOverviewResponse? private(set) var withdrawnPoints = 0 private(set) var pointsInput = "" private(set) var withdrawRecords: [PointWithdrawItem] = [] private(set) var overviewLoading = false private(set) var withdrawLoading = false private(set) var withdrawRefreshing = false private(set) var statusMessage: String? private(set) var errorMessage: String? var onStateChange: (() -> Void)? private let staffIdProvider: () -> Int? private var withdrawPage = 1 private var withdrawCanLoadMore = true private let pageSize = 10 /// 初始化积分兑现 ViewModel。 init(staffIdProvider: @escaping () -> Int? = { Int(AppStore.shared.session.userId.trimmingCharacters(in: .whitespacesAndNewlines)) }) { self.staffIdProvider = staffIdProvider } /// 首次加载积分兑现页数据。 func loadInitial(api: any WalletPageServing) async { await refreshOverview(api: api) await refreshWithdrawList(api: api) } /// 刷新积分概览。 func refreshOverview(api: any WalletPageServing) async { guard let staffId = staffIdProvider(), staffId > 0 else { return } overviewLoading = true errorMessage = nil notifyStateChange() do { let response = try await api.pointOverview(staffId: staffId) overview = response withdrawnPoints = response.withdrawnPoints } catch { errorMessage = error.localizedDescription } overviewLoading = false notifyStateChange() } /// 刷新积分提现记录。 func refreshWithdrawList(api: any WalletPageServing) async { await loadWithdrawList(api: api, refresh: true) } /// 滚动到底部时加载更多积分提现记录。 func loadMoreWithdrawListIfNeeded(currentIndex: Int, api: any WalletPageServing) async { guard currentIndex >= withdrawRecords.count - 2, withdrawCanLoadMore, !withdrawLoading else { return } await loadWithdrawList(api: api, refresh: false) } /// 更新积分输入。 func updatePointsInput(_ value: String) { let digits = value.filter(\.isNumber) guard !digits.isEmpty else { pointsInput = "" notifyStateChange() return } let points = min(Int(digits) ?? 0, withdrawnPoints) pointsInput = String(points) notifyStateChange() } /// 填入全部可提现吗积分。 func withdrawAll() { pointsInput = String(withdrawnPoints) notifyStateChange() } /// 提交积分提现申请。 func applyWithdraw(api: any WalletPageServing) async { let points = Int(pointsInput) ?? 0 guard points > 0 else { errorMessage = "请输入提现积分" notifyStateChange() return } guard points <= withdrawnPoints else { errorMessage = "提现积分不能大于可提现积分" notifyStateChange() return } do { try await api.pointWithdrawApply(points: points, remark: "积分提现申请") statusMessage = "提现申请成功" pointsInput = "" notifyStateChange() await refreshOverview(api: api) await refreshWithdrawList(api: api) } catch { errorMessage = error.localizedDescription.isEmpty ? "提现申请失败" : error.localizedDescription notifyStateChange() } } private func loadWithdrawList(api: any WalletPageServing, refresh: Bool) async { if withdrawLoading && !refresh { return } let targetPage = refresh ? 1 : withdrawPage let previousPage = withdrawPage let previousCanLoadMore = withdrawCanLoadMore withdrawLoading = true withdrawRefreshing = refresh errorMessage = nil if refresh { withdrawCanLoadMore = true withdrawPage = 1 } notifyStateChange() do { let response = try await api.pointWithdrawList(status: nil, page: targetPage, pageSize: pageSize) withdrawRecords = refresh ? response.list : withdrawRecords + response.list withdrawCanLoadMore = withdrawRecords.count < response.total withdrawPage = response.list.isEmpty ? targetPage : targetPage + 1 } catch { if refresh { withdrawPage = previousPage withdrawCanLoadMore = previousCanLoadMore } errorMessage = error.localizedDescription } withdrawLoading = false withdrawRefreshing = false notifyStateChange() } private func notifyStateChange() { onStateChange?() } }