将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
535 lines
18 KiB
Swift
535 lines
18 KiB
Swift
//
|
||
// 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)
|
||
}
|
||
}
|