Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
537
suixinkan_ios/Features/Wallet/ViewModels/WalletViewModels.swift
Normal file
537
suixinkan_ios/Features/Wallet/ViewModels/WalletViewModels.swift
Normal file
@ -0,0 +1,537 @@
|
||||
//
|
||||
// WalletViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@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 {
|
||||
var onChange: (() -> Void)?
|
||||
var summary: WalletSummaryResponse? { didSet { onChange?() } }
|
||||
var pointsOverview: PointOverviewResponse? { didSet { onChange?() } }
|
||||
var selectedTab: WalletLedgerTab = .earnings { didSet { onChange?() } }
|
||||
var selectedFilter: WalletDateFilter = .last30 { didSet { onChange?() } }
|
||||
var earningsGroups: [WalletEarningDetailGroup] = [] { didSet { onChange?() } }
|
||||
var withdrawRecords: [WalletWithdrawRecord] = [] { didSet { onChange?() } }
|
||||
var isLoadingSummary = false { didSet { onChange?() } }
|
||||
var isLoadingList = false { didSet { onChange?() } }
|
||||
var isCheckingWithdraw = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
private let pageSize = 20
|
||||
private var earningsPage = 1
|
||||
private var earningsTotal = 0
|
||||
private var withdrawPage = 1
|
||||
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 {
|
||||
var onChange: (() -> Void)?
|
||||
var info: WithdrawInfoResponse? { didSet { onChange?() } }
|
||||
var amountText = "" { didSet { onChange?() } }
|
||||
var smsCode = "" { didSet { onChange?() } }
|
||||
var isLoading = false { didSet { onChange?() } }
|
||||
var isSubmitting = false { didSet { onChange?() } }
|
||||
var smsCountdown = 0 { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 加载提现申请所需信息。
|
||||
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 {
|
||||
var onChange: (() -> Void)?
|
||||
var bankCard: WalletBankCardInfo? { didSet { onChange?() } }
|
||||
var banks: [String] = [] { didSet { onChange?() } }
|
||||
var areas: [AreaNode] = [] { didSet { onChange?() } }
|
||||
var realName = "" { didSet { onChange?() } }
|
||||
var cardNumber = "" { didSet { onChange?() } }
|
||||
var bankName = "" { didSet { onChange?() } }
|
||||
var branchName = "" { didSet { onChange?() } }
|
||||
var provinceCode = "" { didSet { onChange?() } }
|
||||
var cityCode = "" { didSet { onChange?() } }
|
||||
var smsCode = "" { didSet { onChange?() } }
|
||||
var frontImageData: Data? { didSet { onChange?() } }
|
||||
var backImageData: Data? { didSet { onChange?() } }
|
||||
var frontImageURL = "" { didSet { onChange?() } }
|
||||
var backImageURL = "" { didSet { onChange?() } }
|
||||
var uploadProgress = 0 { didSet { onChange?() } }
|
||||
var isLoading = false { didSet { onChange?() } }
|
||||
var isSubmitting = false { didSet { onChange?() } }
|
||||
var smsCountdown = 0 { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 加载银行卡设置需要的银行、地区和已提交资料。
|
||||
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 {
|
||||
var onChange: (() -> Void)?
|
||||
var overview = PointOverviewResponse() { didSet { onChange?() } }
|
||||
var records: [PointWithdrawItem] = [] { didSet { onChange?() } }
|
||||
var pointsText = "" { didSet { onChange?() } }
|
||||
var isLoading = false { didSet { onChange?() } }
|
||||
var isSubmitting = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
private let pageSize = 20
|
||||
private var page = 1
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user