Add Payment and Wallet modules with home routing integration.
Introduce real payment collection and wallet screens to replace home menu placeholders, wire APIs through RootView, and support bank card OSS uploads plus QR code saving to the photo library. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
538
suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift
Normal file
538
suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift
Normal file
@ -0,0 +1,538 @@
|
||||
//
|
||||
// WalletViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@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
|
||||
@Observable
|
||||
/// 钱包首页 ViewModel,管理钱包汇总、收益明细、提现记录和提现资格流。
|
||||
final class WalletViewModel {
|
||||
var summary: WalletSummaryResponse?
|
||||
var pointsOverview: PointOverviewResponse?
|
||||
var selectedTab: WalletLedgerTab = .earnings
|
||||
var selectedFilter: WalletDateFilter = .last30
|
||||
var earningsGroups: [WalletEarningDetailGroup] = []
|
||||
var withdrawRecords: [WalletWithdrawRecord] = []
|
||||
var isLoadingSummary = false
|
||||
var isLoadingList = false
|
||||
var isCheckingWithdraw = false
|
||||
var errorMessage: String?
|
||||
|
||||
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
|
||||
@Observable
|
||||
/// 提现申请 ViewModel,管理提现信息、金额校验、短信验证码和提交。
|
||||
final class WithdrawApplyViewModel {
|
||||
var info: WithdrawInfoResponse?
|
||||
var amountText = ""
|
||||
var smsCode = ""
|
||||
var isLoading = false
|
||||
var isSubmitting = false
|
||||
var smsCountdown = 0
|
||||
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
|
||||
@Observable
|
||||
/// 银行卡设置 ViewModel,管理银行卡表单、图片上传、短信验证码和提交。
|
||||
final class WithdrawalSettingsViewModel {
|
||||
var bankCard: WalletBankCardInfo?
|
||||
var banks: [String] = []
|
||||
var areas: [AreaNode] = []
|
||||
var realName = ""
|
||||
var cardNumber = ""
|
||||
var bankName = ""
|
||||
var branchName = ""
|
||||
var provinceCode = ""
|
||||
var cityCode = ""
|
||||
var smsCode = ""
|
||||
var frontImageData: Data?
|
||||
var backImageData: Data?
|
||||
var frontImageURL = ""
|
||||
var backImageURL = ""
|
||||
var uploadProgress = 0
|
||||
var isLoading = false
|
||||
var isSubmitting = false
|
||||
var smsCountdown = 0
|
||||
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
|
||||
@Observable
|
||||
/// 积分兑换 ViewModel,管理积分概览、兑换记录、兑换金额校验和提交。
|
||||
final class PointsRedemptionViewModel {
|
||||
var overview = PointOverviewResponse()
|
||||
var records: [PointWithdrawItem] = []
|
||||
var pointsText = ""
|
||||
var isLoading = false
|
||||
var isSubmitting = false
|
||||
var errorMessage: String?
|
||||
|
||||
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