// // WalletViewModels.swift // suixinkan // // Created by Codex on 2026/6/22. // import Foundation import Combine @MainActor /// 钱包实名认证服务协议,抽象实名认证状态读取能力以便测试替换。 protocol WalletRealNameServing { /// 获取当前登录用户实名认证状态。 func realNameInfo() async throws -> RealNameInfoResponse } extension ProfileAPI: WalletRealNameServing {} /// 钱包首页明细 Tab,区分收益明细和提现记录。 enum WalletLedgerTab: String, CaseIterable, Identifiable { case earnings case withdraws var id: String { rawValue } /// Tab 展示标题。 var title: String { switch self { case .earnings: "收益明细" case .withdraws: "提现记录" } } } @MainActor /// 钱包首页 ViewModel,管理钱包汇总、收益明细、提现记录和提现资格流。 final class WalletViewModel: ObservableObject { @Published var summary: WalletSummaryResponse? @Published var pointsOverview: PointOverviewResponse? @Published var selectedTab: WalletLedgerTab = .earnings @Published var selectedFilter: WalletDateFilter = .last30 @Published var earningsGroups: [WalletEarningDetailGroup] = [] @Published var withdrawRecords: [WalletWithdrawRecord] = [] @Published var isLoadingSummary = false @Published var isLoadingList = false @Published var isCheckingWithdraw = false @Published var errorMessage: String? private let pageSize = 20 @Published private var earningsPage = 1 @Published private var earningsTotal = 0 @Published private var withdrawPage = 1 @Published private var withdrawTotal = 0 /// 钱包可提现金额展示文本。 var withdrawableText: String { "¥ \(Self.moneyText(summary?.amountWithdrawable))" } /// 钱包累计金额展示文本。 var totalAmountText: String { "¥ \(Self.moneyText(summary?.amountTotal))" } /// 当前余额展示文本。 var currentBalanceText: String { "¥ \(Self.moneyText(summary?.amountCurrentBalance))" } /// 是否还能继续加载收益明细。 var canLoadMoreEarnings: Bool { earningsGroups.flatMap(\.items).count < earningsTotal } /// 是否还能继续加载提现记录。 var canLoadMoreWithdraws: Bool { withdrawRecords.count < withdrawTotal } /// 首次加载钱包首页数据。 func loadInitial(api: WalletServing, staffId: Int?) async { isLoadingSummary = true errorMessage = nil async let summaryResult = api.walletSummary(type: 0) async let pointsResult: PointOverviewResponse? = { guard let staffId else { return nil } return try? await api.pointOverview(staffId: staffId) }() do { summary = try await summaryResult pointsOverview = await pointsResult } catch { errorMessage = error.localizedDescription } isLoadingSummary = false await reloadList(api: api) } /// 按当前 Tab 和筛选条件刷新列表。 func reloadList(api: WalletServing) async { switch selectedTab { case .earnings: earningsPage = 1 await loadEarnings(api: api, reset: true) case .withdraws: withdrawPage = 1 await loadWithdraws(api: api, reset: true) } } /// 切换钱包明细 Tab 并刷新对应列表。 func selectTab(_ tab: WalletLedgerTab, api: WalletServing) async { guard selectedTab != tab else { return } selectedTab = tab await reloadList(api: api) } /// 切换收益明细时间筛选并重置分页。 func selectFilter(_ filter: WalletDateFilter, api: WalletServing) async { guard selectedFilter != filter else { return } selectedFilter = filter if selectedTab == .earnings { await reloadList(api: api) } } /// 加载下一页当前列表。 func loadMore(api: WalletServing) async { switch selectedTab { case .earnings where canLoadMoreEarnings: earningsPage += 1 await loadEarnings(api: api, reset: false) case .withdraws where canLoadMoreWithdraws: withdrawPage += 1 await loadWithdraws(api: api, reset: false) default: break } } /// 根据实名认证和银行卡状态决定提现下一步。 func resolveWithdrawDecision(profileAPI: WalletRealNameServing, walletAPI: WalletServing) async -> WalletWithdrawDecision { isCheckingWithdraw = true defer { isCheckingWithdraw = false } do { let realName = try await profileAPI.realNameInfo().realNameInfo guard let realName else { return .route(.realNameAuth) } switch realName.auditStatus { case 2: let bankCard = try await walletAPI.bankCardInfo().bankCard guard let bankCard else { return .route(.bankCardSettings) } if bankCard.auditStatus == 2 { return .route(.withdrawApply) } if bankCard.auditStatus == 3 { return .route(.bankCardSettings) } return .message("银行卡审核中,请审核通过后再试") case 3: return .route(.realNameAuth) default: return .message("实名认证审核中,请审核通过后再试") } } catch { return .message(error.localizedDescription) } } /// 加载收益明细分页。 private func loadEarnings(api: WalletServing, reset: Bool) async { isLoadingList = true errorMessage = nil defer { isLoadingList = false } let range = selectedFilter.range() do { let response = try await api.walletEarningDetail( startDate: range.start, endDate: range.end, page: earningsPage, pageSize: pageSize ) earningsTotal = response.total earningsGroups = reset ? response.list : earningsGroups + response.list } catch { if !reset { earningsPage = max(1, earningsPage - 1) } if reset { earningsGroups = [] } errorMessage = error.localizedDescription } } /// 加载提现记录分页。 private func loadWithdraws(api: WalletServing, reset: Bool) async { isLoadingList = true errorMessage = nil defer { isLoadingList = false } do { let response = try await api.walletWithdrawList(page: withdrawPage, pageSize: pageSize) withdrawTotal = response.total withdrawRecords = reset ? response.item : withdrawRecords + response.item } catch { if !reset { withdrawPage = max(1, withdrawPage - 1) } if reset { withdrawRecords = [] } errorMessage = error.localizedDescription } } /// 规范金额展示,空值按 0.00 处理。 static func moneyText(_ rawValue: String?) -> String { let text = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard let decimal = Decimal(string: text), decimal > 0 else { return "0.00" } return NSDecimalNumber(decimal: decimal).stringValue } } @MainActor /// 提现申请 ViewModel,管理提现信息、金额校验、短信验证码和提交。 final class WithdrawApplyViewModel: ObservableObject { @Published var info: WithdrawInfoResponse? @Published var amountText = "" @Published var smsCode = "" @Published var isLoading = false @Published var isSubmitting = false @Published var smsCountdown = 0 @Published var errorMessage: String? /// 加载提现申请所需信息。 func load(api: WalletServing) async { isLoading = true errorMessage = nil defer { isLoading = false } do { info = try await api.withdrawInfo() } catch { errorMessage = error.localizedDescription } } /// 发送提现短信验证码。 func sendSms(api: WalletServing) async { guard smsCountdown == 0 else { return } do { try await api.withdrawSendSms() smsCountdown = 60 } catch { errorMessage = error.localizedDescription } } /// 提交提现申请,成功后清空表单。 func submit(api: WalletServing) async -> Bool { guard !isSubmitting else { return false } guard let amount = PaymentCollectionViewModel.normalizedMoney(amountText) else { errorMessage = "请输入有效提现金额" return false } guard let info else { errorMessage = "提现信息未加载" return false } guard (Decimal(string: amount) ?? 0) <= (Decimal(string: info.amountWithdrawable) ?? 0) else { errorMessage = "提现金额不能超过可提现金额" return false } guard !smsCode.walletTrimmed.isEmpty else { errorMessage = "请输入短信验证码" return false } isSubmitting = true defer { isSubmitting = false } do { try await api.withdrawApply(amount: amount, smsCode: smsCode.walletTrimmed) amountText = "" smsCode = "" return true } catch { errorMessage = error.localizedDescription return false } } } @MainActor /// 银行卡设置 ViewModel,管理银行卡表单、图片上传、短信验证码和提交。 final class WithdrawalSettingsViewModel: ObservableObject { @Published var bankCard: WalletBankCardInfo? @Published var banks: [String] = [] @Published var areas: [AreaNode] = [] @Published var realName = "" @Published var cardNumber = "" @Published var bankName = "" @Published var branchName = "" @Published var provinceCode = "" @Published var cityCode = "" @Published var smsCode = "" @Published var frontImageData: Data? @Published var backImageData: Data? @Published var frontImageURL = "" @Published var backImageURL = "" @Published var uploadProgress = 0 @Published var isLoading = false @Published var isSubmitting = false @Published var smsCountdown = 0 @Published var errorMessage: String? /// 加载银行卡设置需要的银行、地区和已提交资料。 func load(api: WalletServing) async { isLoading = true errorMessage = nil defer { isLoading = false } async let bankCardResult = api.bankCardInfo() async let bankListResult = api.bankList() async let areaResult = api.areas() do { let (cardResponse, bankResponse, areaResponse) = try await (bankCardResult, bankListResult, areaResult) bankCard = cardResponse.bankCard banks = bankResponse.banks areas = areaResponse apply(cardResponse.bankCard) } catch { errorMessage = error.localizedDescription } } /// 发送银行卡设置短信验证码。 func sendSms(api: WalletServing) async { guard smsCountdown == 0 else { return } do { try await api.bankCardVerifyCode() smsCountdown = 60 } catch { errorMessage = error.localizedDescription } } /// 上传图片并提交银行卡资料。 func submit(api: WalletServing, uploader: OSSUploadServing, scenicId: Int?) async -> Bool { guard !isSubmitting else { return false } guard let scenicId else { errorMessage = "请先选择景区" return false } guard validateForm() else { return false } isSubmitting = true uploadProgress = 0 defer { isSubmitting = false } do { let frontURL = try await resolvedImageURL( currentURL: frontImageURL, data: frontImageData, fileName: "bank_card_front.jpg", uploader: uploader, scenicId: scenicId ) let backURL = try await resolvedImageURL( currentURL: backImageURL, data: backImageData, fileName: "bank_card_back.jpg", uploader: uploader, scenicId: scenicId ) let request = UpdateBankInfoRequest( realName: realName.walletTrimmed, cardNumber: cardNumber.walletTrimmed, bankName: bankName.walletTrimmed, branchName: branchName.walletTrimmed, frontUrl: frontURL, backUrl: backURL, provinceCode: provinceCode.walletTrimmed, cityCode: cityCode.walletTrimmed, smsVerifyCode: smsCode.walletTrimmed ) try await api.updateBankInfo(request) frontImageURL = frontURL backImageURL = backURL frontImageData = nil backImageData = nil return true } catch { errorMessage = error.localizedDescription return false } } /// 将已有银行卡资料回填到表单。 private func apply(_ info: WalletBankCardInfo?) { guard let info else { return } realName = info.realName cardNumber = info.cardNumber bankName = info.bankName branchName = info.branchName provinceCode = info.provinceCode ?? "" cityCode = info.cityCode ?? "" frontImageURL = info.frontUrl ?? "" backImageURL = info.backUrl ?? "" } /// 校验银行卡表单必填项。 private func validateForm() -> Bool { if realName.walletTrimmed.isEmpty { errorMessage = "请输入持卡人姓名"; return false } if cardNumber.walletTrimmed.isEmpty { errorMessage = "请输入银行卡号"; return false } if bankName.walletTrimmed.isEmpty { errorMessage = "请选择开户银行"; return false } if branchName.walletTrimmed.isEmpty { errorMessage = "请输入开户支行"; return false } if provinceCode.walletTrimmed.isEmpty || cityCode.walletTrimmed.isEmpty { errorMessage = "请选择开户地区"; return false } if frontImageURL.walletTrimmed.isEmpty && frontImageData == nil { errorMessage = "请选择银行卡正面照片"; return false } if backImageURL.walletTrimmed.isEmpty && backImageData == nil { errorMessage = "请选择银行卡反面照片"; return false } if smsCode.walletTrimmed.isEmpty { errorMessage = "请输入短信验证码"; return false } return true } /// 返回最终图片 URL,本地选择的新图会先上传 OSS。 private func resolvedImageURL( currentURL: String, data: Data?, fileName: String, uploader: OSSUploadServing, scenicId: Int ) async throws -> String { guard let data else { return currentURL } return try await uploader.uploadBankCardImage(data: data, fileName: fileName, scenicId: scenicId) { [weak self] progress in self?.uploadProgress = progress } } } @MainActor /// 积分兑换 ViewModel,管理积分概览、兑换记录、兑换金额校验和提交。 final class PointsRedemptionViewModel: ObservableObject { @Published var overview = PointOverviewResponse() @Published var records: [PointWithdrawItem] = [] @Published var pointsText = "" @Published var isLoading = false @Published var isSubmitting = false @Published var errorMessage: String? private let pageSize = 20 @Published private var page = 1 @Published private var total = 0 /// 是否还能继续加载兑换记录。 var canLoadMore: Bool { records.count < total } /// 加载积分概览和兑换记录。 func load(api: WalletServing, staffId: Int?) async { guard let staffId else { errorMessage = "缺少账号信息" return } isLoading = true errorMessage = nil page = 1 defer { isLoading = false } do { async let overviewResult = api.pointOverview(staffId: staffId) async let listResult = api.pointWithdrawList(status: nil, page: page, pageSize: pageSize) overview = try await overviewResult let list = try await listResult total = list.total records = list.list } catch { errorMessage = error.localizedDescription } } /// 加载下一页积分兑换记录。 func loadMore(api: WalletServing) async { guard canLoadMore else { return } page += 1 do { let response = try await api.pointWithdrawList(status: nil, page: page, pageSize: pageSize) total = response.total records += response.list } catch { page = max(1, page - 1) errorMessage = error.localizedDescription } } /// 将可兑换积分全部填入表单。 func fillAllPoints() { pointsText = String(overview.withdrawnPoints) } /// 提交积分兑换申请,成功后刷新概览和列表。 func submit(api: WalletServing, staffId: Int?) async -> Bool { guard !isSubmitting else { return false } guard let points = Int(pointsText.walletTrimmed), points > 0 else { errorMessage = "请输入有效兑换积分" return false } guard points <= overview.withdrawnPoints else { errorMessage = "兑换积分不能超过可兑换积分" return false } guard let staffId else { errorMessage = "缺少账号信息" return false } isSubmitting = true defer { isSubmitting = false } do { try await api.pointWithdrawApply(points: points, remark: "积分提现申请") pointsText = "" await load(api: api, staffId: staffId) return true } catch { errorMessage = error.localizedDescription return false } } } private extension String { /// 钱包模块内部使用的去空白文本,避免依赖其他文件的 fileprivate 扩展。 var walletTrimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) } }