feat: update wallet punch point and report success

This commit is contained in:
2026-07-09 14:27:18 +08:00
parent 2970f1514b
commit 43e6133c21
34 changed files with 6671 additions and 71 deletions

View File

@ -0,0 +1,598 @@
//
// 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 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.userId.trimmingCharacters(in: .whitespacesAndNewlines))
},
calendar: Calendar = Calendar(identifier: .gregorian)
) {
self.staffIdProvider = staffIdProvider
self.calendar = calendar
}
///
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)
}
///
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
withdrawLoading = true
withdrawRefreshing = refresh
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 {
withdrawRecords = []
withdrawCanLoadMore = false
}
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 range = dateRange(for: filter)
transactionLoading = true
transactionRefreshing = refresh
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 {
transactionGroups = []
transactionCanLoadMore = false
}
errorMessage = error.localizedDescription
}
transactionLoading = false
transactionRefreshing = false
notifyStateChange()
}
private func dateRange(for filter: WalletFilter, now: Date = Date()) -> (start: String, end: String) {
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)
}
/// 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.filter(\.isNumber)
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 {
validationMessage == nil && !submitting
}
///
var validationMessage: String? {
guard let withdrawInfo else { return "提现信息加载中" }
guard !amount.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return "请输入提现金额" }
guard let amountDecimal = Decimal(string: amount), amountDecimal > 0 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.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
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 = points > 0 ? 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
withdrawLoading = true
withdrawRefreshing = refresh
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 {
withdrawRecords = []
withdrawCanLoadMore = false
}
errorMessage = error.localizedDescription
}
withdrawLoading = false
withdrawRefreshing = false
notifyStateChange()
}
private func notifyStateChange() {
onStateChange?()
}
}