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

@ -22,8 +22,41 @@ protocol WalletServing {
}
@MainActor
/// API
final class WalletAPI: WalletServing {
///
protocol WalletPageServing: WalletServing {
///
func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse
///
func earningDetail(
startDate: String,
endDate: String,
page: Int,
pageSize: Int
) async throws -> EarningDetailResponse
///
func withdrawInfo() async throws -> WithdrawInfoResponse
///
func withdrawSendSMS() async throws
///
func withdrawApply(amount: String, smsCode: String) async throws
///
func pointOverview(staffId: Int) async throws -> PointOverviewResponse
///
func pointWithdrawApply(points: Int, remark: String) async throws
///
func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse
}
@MainActor
/// API
final class WalletAPI: WalletPageServing {
private let client: APIClient
/// API
@ -64,4 +97,105 @@ final class WalletAPI: WalletServing {
)
)
}
///
func walletWithdrawList(page: Int = 1, pageSize: Int = 10) async throws -> WalletWithdrawListResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/wallet/withdraw-list",
queryItems: [
URLQueryItem(name: "page", value: String(max(page, 1))),
URLQueryItem(name: "page_size", value: String(max(pageSize, 1))),
]
)
)
}
///
func earningDetail(
startDate: String,
endDate: String,
page: Int = 1,
pageSize: Int = 10
) async throws -> EarningDetailResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/wallet/earning-detail",
queryItems: [
URLQueryItem(name: "start_date", value: startDate),
URLQueryItem(name: "end_date", value: endDate),
URLQueryItem(name: "page", value: String(max(page, 1))),
URLQueryItem(name: "page_size", value: String(max(pageSize, 1))),
]
)
)
}
///
func withdrawInfo() async throws -> WithdrawInfoResponse {
try await client.send(
APIRequest(method: .get, path: "/api/yf-handset-app/photog/wallet/withdraw-info")
)
}
///
func withdrawSendSMS() async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/wallet/withdraw-send-sms",
body: EmptyPayload()
)
)
}
///
func withdrawApply(amount: String, smsCode: String) async throws {
let request = WithdrawApplyRequest(amount: amount, smsCode: smsCode)
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/wallet/withdraw-apply",
body: request
)
)
}
///
func pointOverview(staffId: Int) async throws -> PointOverviewResponse {
try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/point/overview",
queryItems: [URLQueryItem(name: "staff_id", value: String(staffId))],
body: EmptyPayload()
)
)
}
///
func pointWithdrawApply(points: Int, remark: String = "积分提现申请") async throws {
let request = PointWithdrawApplyRequest(points: points, remark: remark)
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/point/withdraw/apply",
body: request
)
)
}
///
func pointWithdrawList(status: Int? = nil, page: Int = 1, pageSize: Int = 10) async throws -> PointWithdrawListResponse {
let request = PointWithdrawListRequest(status: status, page: max(page, 1), pageSize: max(pageSize, 1))
return try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/point/withdraw/list",
body: request
)
)
}
}

View File

@ -45,6 +45,90 @@ struct WalletTransactionListResponse: Decodable, Equatable {
}
}
/// Tab
struct WalletWithdrawListResponse: Decodable, Equatable {
let total: Int
let item: [WalletWithdrawItem]
///
init(total: Int = 0, item: [WalletWithdrawItem] = []) {
self.total = total
self.item = item
}
}
///
struct WalletWithdrawItem: Decodable, Hashable {
let id: Int
let amount: String
let createdAt: String
let settlementStatus: Int
let withdrawStatus: Int
let expectedAt: String
let auditTime: String
let auditRemark: String
let completedAt: String
let errorReason: String
let statusLabel: String
enum CodingKeys: String, CodingKey {
case id
case amount
case createdAt = "created_at"
case settlementStatus = "settlement_status"
case withdrawStatus = "withdraw_status"
case expectedAt = "expected_at"
case auditTime = "audit_time"
case auditRemark = "audit_remark"
case completedAt = "completed_at"
case errorReason = "error_reason"
case statusLabel = "status_label"
}
///
init(
id: Int = 0,
amount: String = "",
createdAt: String = "",
settlementStatus: Int = 0,
withdrawStatus: Int = 0,
expectedAt: String = "",
auditTime: String = "",
auditRemark: String = "",
completedAt: String = "",
errorReason: String = "",
statusLabel: String = ""
) {
self.id = id
self.amount = amount
self.createdAt = createdAt
self.settlementStatus = settlementStatus
self.withdrawStatus = withdrawStatus
self.expectedAt = expectedAt
self.auditTime = auditTime
self.auditRemark = auditRemark
self.completedAt = completedAt
self.errorReason = errorReason
self.statusLabel = statusLabel
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeWalletLossyInt(forKey: .id) ?? 0
amount = try container.decodeWalletLossyString(forKey: .amount)
createdAt = try container.decodeWalletLossyString(forKey: .createdAt)
settlementStatus = try container.decodeWalletLossyInt(forKey: .settlementStatus) ?? 0
withdrawStatus = try container.decodeWalletLossyInt(forKey: .withdrawStatus) ?? 0
expectedAt = try container.decodeWalletLossyString(forKey: .expectedAt)
auditTime = try container.decodeWalletLossyString(forKey: .auditTime)
auditRemark = try container.decodeWalletLossyString(forKey: .auditRemark)
completedAt = try container.decodeWalletLossyString(forKey: .completedAt)
errorReason = try container.decodeWalletLossyString(forKey: .errorReason)
statusLabel = try container.decodeWalletLossyString(forKey: .statusLabel)
}
}
///
struct WalletTransactionItem: Decodable, Hashable {
let id: Int
@ -97,6 +181,392 @@ struct WalletTransactionItem: Decodable, Hashable {
}
}
///
struct EarningDetailResponse: Decodable, Equatable {
let totalAmount: String
let totalPoints: Int
let total: Int
let list: [EarningDetailGroup]
enum CodingKeys: String, CodingKey {
case totalAmount = "total_amount"
case totalPoints = "total_points"
case total
case list
}
///
init(totalAmount: String = "", totalPoints: Int = 0, total: Int = 0, list: [EarningDetailGroup] = []) {
self.totalAmount = totalAmount
self.totalPoints = totalPoints
self.total = total
self.list = list
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
totalAmount = try container.decodeWalletLossyString(forKey: .totalAmount)
totalPoints = try container.decodeWalletLossyInt(forKey: .totalPoints) ?? 0
total = try container.decodeWalletLossyInt(forKey: .total) ?? 0
list = try container.decodeIfPresent([EarningDetailGroup].self, forKey: .list) ?? []
}
}
///
struct EarningDetailGroup: Decodable, Hashable {
let date: String
let dayAmount: String
let dayPoints: Int
let items: [EarningDetailItem]
enum CodingKeys: String, CodingKey {
case date
case dayAmount = "day_amount"
case dayPoints = "day_points"
case items
}
///
init(date: String = "", dayAmount: String = "", dayPoints: Int = 0, items: [EarningDetailItem] = []) {
self.date = date
self.dayAmount = dayAmount
self.dayPoints = dayPoints
self.items = items
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
date = try container.decodeWalletLossyString(forKey: .date)
dayAmount = try container.decodeWalletLossyString(forKey: .dayAmount)
dayPoints = try container.decodeWalletLossyInt(forKey: .dayPoints) ?? 0
items = try container.decodeIfPresent([EarningDetailItem].self, forKey: .items) ?? []
}
}
///
struct EarningDetailItem: Decodable, Hashable {
let id: Int
let amount: String
let points: Int
let type: String
let typeLabel: String
let orderNumberSuffix: String
let createdAt: String
let withdrawLabel: String
let source: String
enum CodingKeys: String, CodingKey {
case id
case amount
case points
case type
case typeLabel = "type_label"
case orderNumberSuffix = "order_number_suffix"
case createdAt = "created_at"
case withdrawLabel = "withdraw_label"
case source
}
///
init(
id: Int = 0,
amount: String = "",
points: Int = 0,
type: String = "",
typeLabel: String = "",
orderNumberSuffix: String = "",
createdAt: String = "",
withdrawLabel: String = "",
source: String = ""
) {
self.id = id
self.amount = amount
self.points = points
self.type = type
self.typeLabel = typeLabel
self.orderNumberSuffix = orderNumberSuffix
self.createdAt = createdAt
self.withdrawLabel = withdrawLabel
self.source = source
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeWalletLossyInt(forKey: .id) ?? 0
amount = try container.decodeWalletLossyString(forKey: .amount)
points = try container.decodeWalletLossyInt(forKey: .points) ?? 0
type = try container.decodeWalletLossyString(forKey: .type)
typeLabel = try container.decodeWalletLossyString(forKey: .typeLabel)
orderNumberSuffix = try container.decodeWalletLossyString(forKey: .orderNumberSuffix)
createdAt = try container.decodeWalletLossyString(forKey: .createdAt)
withdrawLabel = try container.decodeWalletLossyString(forKey: .withdrawLabel)
source = try container.decodeWalletLossyString(forKey: .source)
}
}
///
struct WithdrawInfoResponse: Decodable, Equatable {
let amountWithdrawable: String
let minWithdrawAmount: String
let maxSingleWithdrawAmount: String
let maxDailyWithdrawAmount: String
let userPhone: String
let bankCard: WithdrawBankCardInfo
let settlement: String
let withdrawInfo: [String]
enum CodingKeys: String, CodingKey {
case amountWithdrawable = "amount_withdrawable"
case minWithdrawAmount = "min_withdraw_amount"
case maxSingleWithdrawAmount = "max_single_withdraw_amount"
case maxDailyWithdrawAmount = "max_daily_withdraw_amount"
case userPhone = "user_phone"
case bankCard = "bank_card"
case settlement
case withdrawInfo = "withdraw_info"
}
///
init(
amountWithdrawable: String = "",
minWithdrawAmount: String = "",
maxSingleWithdrawAmount: String = "",
maxDailyWithdrawAmount: String = "",
userPhone: String = "",
bankCard: WithdrawBankCardInfo = WithdrawBankCardInfo(),
settlement: String = "",
withdrawInfo: [String] = []
) {
self.amountWithdrawable = amountWithdrawable
self.minWithdrawAmount = minWithdrawAmount
self.maxSingleWithdrawAmount = maxSingleWithdrawAmount
self.maxDailyWithdrawAmount = maxDailyWithdrawAmount
self.userPhone = userPhone
self.bankCard = bankCard
self.settlement = settlement
self.withdrawInfo = withdrawInfo
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
amountWithdrawable = try container.decodeWalletLossyString(forKey: .amountWithdrawable)
minWithdrawAmount = try container.decodeWalletLossyString(forKey: .minWithdrawAmount)
maxSingleWithdrawAmount = try container.decodeWalletLossyString(forKey: .maxSingleWithdrawAmount)
maxDailyWithdrawAmount = try container.decodeWalletLossyString(forKey: .maxDailyWithdrawAmount)
userPhone = try container.decodeWalletLossyString(forKey: .userPhone)
bankCard = try container.decodeIfPresent(WithdrawBankCardInfo.self, forKey: .bankCard) ?? WithdrawBankCardInfo()
settlement = try container.decodeWalletLossyString(forKey: .settlement)
withdrawInfo = try container.decodeIfPresent([String].self, forKey: .withdrawInfo) ?? []
}
}
///
struct WithdrawBankCardInfo: Decodable, Equatable {
let realName: String
let bankName: String
let cardNumber: String
enum CodingKeys: String, CodingKey {
case realName = "real_name"
case bankName = "bank_name"
case cardNumber = "card_number"
}
///
init(realName: String = "", bankName: String = "", cardNumber: String = "") {
self.realName = realName
self.bankName = bankName
self.cardNumber = cardNumber
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
realName = try container.decodeWalletLossyString(forKey: .realName)
bankName = try container.decodeWalletLossyString(forKey: .bankName)
cardNumber = try container.decodeWalletLossyString(forKey: .cardNumber)
}
}
///
struct WithdrawApplyRequest: Encodable, Equatable {
let amount: String
let smsCode: String
enum CodingKeys: String, CodingKey {
case amount
case smsCode = "sms_code"
}
}
///
struct PointOverviewResponse: Decodable, Equatable {
let totalPoints: Int
let availablePoints: Int
let withdrawnPoints: Int
let pendingPoints: Int
let time: String
enum CodingKeys: String, CodingKey {
case totalPoints = "total_points"
case availablePoints = "available_points"
case withdrawnPoints = "withdrawn_points"
case pendingPoints = "pending_points"
case time
}
///
init(totalPoints: Int = 0, availablePoints: Int = 0, withdrawnPoints: Int = 0, pendingPoints: Int = 0, time: String = "") {
self.totalPoints = totalPoints
self.availablePoints = availablePoints
self.withdrawnPoints = withdrawnPoints
self.pendingPoints = pendingPoints
self.time = time
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
totalPoints = try container.decodeWalletLossyInt(forKey: .totalPoints) ?? 0
availablePoints = try container.decodeWalletLossyInt(forKey: .availablePoints) ?? 0
withdrawnPoints = try container.decodeWalletLossyInt(forKey: .withdrawnPoints) ?? 0
pendingPoints = try container.decodeWalletLossyInt(forKey: .pendingPoints) ?? 0
time = try container.decodeWalletLossyString(forKey: .time)
}
}
///
struct PointWithdrawItem: Decodable, Hashable {
let id: Int
let applyNo: String
let merchantType: Int
let merchantId: Int
let merchantName: String
let points: Int
let amount: Double?
let status: Int
let rejectReason: String
let paymentVoucher: String
let paymentTime: String
let estimatedReceiptTime: String
let reviewedAt: String
let reviewedBy: String
let createdAt: String
let updatedAt: String
enum CodingKeys: String, CodingKey {
case id
case applyNo = "apply_no"
case merchantType = "merchant_type"
case merchantId = "merchant_id"
case merchantName = "merchant_name"
case points
case amount
case status
case rejectReason = "reject_reason"
case paymentVoucher = "payment_voucher"
case paymentTime = "payment_time"
case estimatedReceiptTime = "estimated_receipt_time"
case reviewedAt = "reviewed_at"
case reviewedBy = "reviewed_by"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
///
init(
id: Int = 0,
applyNo: String = "",
merchantType: Int = 0,
merchantId: Int = 0,
merchantName: String = "",
points: Int = 0,
amount: Double? = nil,
status: Int = 0,
rejectReason: String = "",
paymentVoucher: String = "",
paymentTime: String = "",
estimatedReceiptTime: String = "",
reviewedAt: String = "",
reviewedBy: String = "",
createdAt: String = "",
updatedAt: String = ""
) {
self.id = id
self.applyNo = applyNo
self.merchantType = merchantType
self.merchantId = merchantId
self.merchantName = merchantName
self.points = points
self.amount = amount
self.status = status
self.rejectReason = rejectReason
self.paymentVoucher = paymentVoucher
self.paymentTime = paymentTime
self.estimatedReceiptTime = estimatedReceiptTime
self.reviewedAt = reviewedAt
self.reviewedBy = reviewedBy
self.createdAt = createdAt
self.updatedAt = updatedAt
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeWalletLossyInt(forKey: .id) ?? 0
applyNo = try container.decodeWalletLossyString(forKey: .applyNo)
merchantType = try container.decodeWalletLossyInt(forKey: .merchantType) ?? 0
merchantId = try container.decodeWalletLossyInt(forKey: .merchantId) ?? 0
merchantName = try container.decodeWalletLossyString(forKey: .merchantName)
points = try container.decodeWalletLossyInt(forKey: .points) ?? 0
amount = try container.decodeWalletLossyDouble(forKey: .amount)
status = try container.decodeWalletLossyInt(forKey: .status) ?? 0
rejectReason = try container.decodeWalletLossyString(forKey: .rejectReason)
paymentVoucher = try container.decodeWalletLossyString(forKey: .paymentVoucher)
paymentTime = try container.decodeWalletLossyString(forKey: .paymentTime)
estimatedReceiptTime = try container.decodeWalletLossyString(forKey: .estimatedReceiptTime)
reviewedAt = try container.decodeWalletLossyString(forKey: .reviewedAt)
reviewedBy = try container.decodeWalletLossyString(forKey: .reviewedBy)
createdAt = try container.decodeWalletLossyString(forKey: .createdAt)
updatedAt = try container.decodeWalletLossyString(forKey: .updatedAt)
}
}
///
struct PointWithdrawListResponse: Decodable, Equatable {
let total: Int
let list: [PointWithdrawItem]
///
init(total: Int = 0, list: [PointWithdrawItem] = []) {
self.total = total
self.list = list
}
}
///
struct PointWithdrawApplyRequest: Encodable, Equatable {
let points: Int
let remark: String
}
///
struct PointWithdrawListRequest: Encodable, Equatable {
let status: Int?
let page: Int
let pageSize: Int
enum CodingKeys: String, CodingKey {
case status
case page
case pageSize = "page_size"
}
}
private extension KeyedDecodingContainer {
func decodeWalletLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) {
@ -124,4 +594,18 @@ private extension KeyedDecodingContainer {
}
return nil
}
func decodeWalletLossyDouble(forKey key: Key) throws -> Double? {
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return Double(value)
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
return Double(text)
}
return nil
}
}

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?()
}
}