Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
202
suixinkan_ios/Features/Wallet/API/WalletAPI.swift
Normal file
202
suixinkan_ios/Features/Wallet/API/WalletAPI.swift
Normal file
@ -0,0 +1,202 @@
|
||||
//
|
||||
// WalletAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 钱包模块服务协议,定义钱包、提现、银行卡和积分接口能力。
|
||||
@MainActor
|
||||
protocol WalletServing {
|
||||
/// 获取钱包汇总数据。
|
||||
func walletSummary(type: Int) async throws -> WalletSummaryResponse
|
||||
|
||||
/// 获取钱包收益明细分页。
|
||||
func walletEarningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> WalletEarningDetailResponse
|
||||
|
||||
/// 获取提现记录分页。
|
||||
func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse
|
||||
|
||||
/// 获取提现申请所需信息。
|
||||
func withdrawInfo() async throws -> WithdrawInfoResponse
|
||||
|
||||
/// 发送提现短信验证码。
|
||||
func withdrawSendSms() async throws
|
||||
|
||||
/// 提交提现申请。
|
||||
func withdrawApply(amount: String, smsCode: String) async throws
|
||||
|
||||
/// 获取银行卡审核和资料信息。
|
||||
func bankCardInfo() async throws -> BankCardInfoResponse
|
||||
|
||||
/// 获取银行列表。
|
||||
func bankList() async throws -> BankListResponse
|
||||
|
||||
/// 获取省市区列表。
|
||||
func areas() async throws -> [AreaNode]
|
||||
|
||||
/// 发送银行卡设置短信验证码。
|
||||
func bankCardVerifyCode() async throws
|
||||
|
||||
/// 更新银行卡资料。
|
||||
func updateBankInfo(_ request: UpdateBankInfoRequest) 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 {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化钱包 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取钱包汇总数据。
|
||||
func walletSummary(type: Int = 0) async throws -> WalletSummaryResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/wallet/summary",
|
||||
queryItems: [URLQueryItem(name: "type", value: String(type))]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取钱包收益明细分页。
|
||||
func walletEarningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> WalletEarningDetailResponse {
|
||||
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(page)),
|
||||
URLQueryItem(name: "page_size", value: String(pageSize))
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取提现记录分页。
|
||||
func walletWithdrawList(page: Int, pageSize: Int) 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(page)),
|
||||
URLQueryItem(name: "page_size", value: String(pageSize))
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取提现申请所需信息。
|
||||
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 _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/wallet/withdraw-apply",
|
||||
body: WithdrawApplyRequest(amount: amount, smsCode: smsCode)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取银行卡审核和资料信息。
|
||||
func bankCardInfo() async throws -> BankCardInfoResponse {
|
||||
try await client.send(
|
||||
APIRequest(method: .get, path: "/api/yf-handset-app/photog/wallet/bank-card/info")
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取银行列表。
|
||||
func bankList() async throws -> BankListResponse {
|
||||
try await client.send(
|
||||
APIRequest(method: .get, path: "/api/yf-handset-app/photog/wallet/bank-list")
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取省市区列表。
|
||||
func areas() async throws -> [AreaNode] {
|
||||
try await client.send(
|
||||
APIRequest(method: .get, path: "/api/app/config/areas")
|
||||
)
|
||||
}
|
||||
|
||||
/// 发送银行卡设置短信验证码。
|
||||
func bankCardVerifyCode() async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/wallet/bank-card/sms-verify-code", body: EmptyPayload())
|
||||
)
|
||||
}
|
||||
|
||||
/// 更新银行卡资料。
|
||||
func updateBankInfo(_ request: UpdateBankInfoRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/wallet/bank-card/update", 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 _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/point/withdraw/apply",
|
||||
body: PointWithdrawApplyRequest(points: points, remark: remark)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取积分兑换记录。
|
||||
func pointWithdrawList(status: Int? = nil, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/point/withdraw/list",
|
||||
body: PointWithdrawListRequest(status: status, page: page, pageSize: pageSize)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension WalletAPI: WalletServing {}
|
||||
627
suixinkan_ios/Features/Wallet/Models/WalletModels.swift
Normal file
627
suixinkan_ios/Features/Wallet/Models/WalletModels.swift
Normal file
@ -0,0 +1,627 @@
|
||||
//
|
||||
// WalletModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 钱包汇总响应实体,表示累计收益、当前余额和可提现金额。
|
||||
struct WalletSummaryResponse: Decodable, Equatable {
|
||||
let amountTotal: String
|
||||
let amountCurrentBalance: String
|
||||
let amountWithdrawable: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case amountTotal = "amount_total"
|
||||
case amountCurrentBalance = "amount_current_balance"
|
||||
case amountWithdrawable = "amount_withdrawable"
|
||||
}
|
||||
|
||||
/// 创建钱包汇总响应实体,主要用于测试和空状态。
|
||||
init(amountTotal: String = "0.00", amountCurrentBalance: String = "0.00", amountWithdrawable: String = "0.00") {
|
||||
self.amountTotal = amountTotal
|
||||
self.amountCurrentBalance = amountCurrentBalance
|
||||
self.amountWithdrawable = amountWithdrawable
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容金额字段类型不稳定。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
amountTotal = try container.decodeLossyString(forKey: .amountTotal)
|
||||
amountCurrentBalance = try container.decodeLossyString(forKey: .amountCurrentBalance)
|
||||
amountWithdrawable = try container.decodeLossyString(forKey: .amountWithdrawable)
|
||||
}
|
||||
}
|
||||
|
||||
/// 钱包收益明细响应实体,表示日分组收益和分页总数。
|
||||
struct WalletEarningDetailResponse: Decodable, Equatable {
|
||||
let totalAmount: String
|
||||
let totalPoints: Int
|
||||
let total: Int
|
||||
let list: [WalletEarningDetailGroup]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case totalAmount = "total_amount"
|
||||
case totalPoints = "total_points"
|
||||
case total
|
||||
case list
|
||||
}
|
||||
|
||||
/// 创建收益明细响应实体,主要用于测试替身。
|
||||
init(totalAmount: String = "0.00", totalPoints: Int = 0, total: Int = 0, list: [WalletEarningDetailGroup] = []) {
|
||||
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.decodeLossyString(forKey: .totalAmount)
|
||||
totalPoints = try container.decodeLossyInt(forKey: .totalPoints) ?? 0
|
||||
total = try container.decodeLossyInt(forKey: .total) ?? 0
|
||||
list = try container.decodeIfPresent([WalletEarningDetailGroup].self, forKey: .list) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 钱包收益日分组实体,表示某一天的收益、积分和明细。
|
||||
struct WalletEarningDetailGroup: Decodable, Equatable, Identifiable {
|
||||
let date: String
|
||||
let dayAmount: String
|
||||
let dayPoints: Int
|
||||
let items: [WalletEarningDetailItem]
|
||||
|
||||
var id: String { date }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case date
|
||||
case dayAmount = "day_amount"
|
||||
case dayPoints = "day_points"
|
||||
case items
|
||||
}
|
||||
|
||||
/// 创建收益日分组实体,主要用于测试替身。
|
||||
init(date: String, dayAmount: String = "0.00", dayPoints: Int = 0, items: [WalletEarningDetailItem] = []) {
|
||||
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.decodeLossyString(forKey: .date)
|
||||
dayAmount = try container.decodeLossyString(forKey: .dayAmount)
|
||||
dayPoints = try container.decodeLossyInt(forKey: .dayPoints) ?? 0
|
||||
items = try container.decodeIfPresent([WalletEarningDetailItem].self, forKey: .items) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 钱包收益明细实体,表示一笔订单收益或积分来源。
|
||||
struct WalletEarningDetailItem: Decodable, Equatable, Identifiable {
|
||||
let id: Int64
|
||||
let amount: String
|
||||
let points: Int
|
||||
let type: Int
|
||||
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: Int64 = 0,
|
||||
amount: String = "",
|
||||
points: Int = 0,
|
||||
type: Int = 0,
|
||||
typeLabel: String = "",
|
||||
orderNumberSuffix: String = "",
|
||||
createdAt: String = "",
|
||||
withdrawLabel: String? = nil,
|
||||
source: String? = nil
|
||||
) {
|
||||
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 = Int64(try container.decodeLossyInt(forKey: .id) ?? 0)
|
||||
amount = try container.decodeLossyString(forKey: .amount)
|
||||
points = try container.decodeLossyInt(forKey: .points) ?? 0
|
||||
type = try container.decodeLossyInt(forKey: .type) ?? 0
|
||||
typeLabel = try container.decodeLossyString(forKey: .typeLabel)
|
||||
orderNumberSuffix = try container.decodeLossyString(forKey: .orderNumberSuffix)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
withdrawLabel = try container.decodeIfPresent(String.self, forKey: .withdrawLabel)
|
||||
source = try container.decodeIfPresent(String.self, forKey: .source)
|
||||
}
|
||||
}
|
||||
|
||||
/// 钱包提现记录响应实体,表示提现分页列表。
|
||||
struct WalletWithdrawListResponse: Decodable, Equatable {
|
||||
let total: Int
|
||||
let item: [WalletWithdrawRecord]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case total
|
||||
case item
|
||||
}
|
||||
|
||||
/// 创建提现记录响应实体,主要用于测试替身。
|
||||
init(total: Int = 0, item: [WalletWithdrawRecord] = []) {
|
||||
self.total = total
|
||||
self.item = item
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容 total 字段类型不稳定。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
total = try container.decodeLossyInt(forKey: .total) ?? 0
|
||||
item = try container.decodeIfPresent([WalletWithdrawRecord].self, forKey: .item) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 钱包提现记录实体,表示一笔提现申请。
|
||||
struct WalletWithdrawRecord: Decodable, Equatable, Identifiable {
|
||||
let id: Int64
|
||||
let amount: String
|
||||
let createdAt: String
|
||||
let statusLabel: String
|
||||
let expectedAt: String?
|
||||
let auditTime: String?
|
||||
let completedAt: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case amount
|
||||
case createdAt = "created_at"
|
||||
case statusLabel = "status_label"
|
||||
case expectedAt = "expected_at"
|
||||
case auditTime = "audit_time"
|
||||
case completedAt = "completed_at"
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容字段类型不稳定。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = Int64(try container.decodeLossyInt(forKey: .id) ?? 0)
|
||||
amount = try container.decodeLossyString(forKey: .amount)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
statusLabel = try container.decodeLossyString(forKey: .statusLabel)
|
||||
expectedAt = try container.decodeIfPresent(String.self, forKey: .expectedAt)
|
||||
auditTime = try container.decodeIfPresent(String.self, forKey: .auditTime)
|
||||
completedAt = try container.decodeIfPresent(String.self, forKey: .completedAt)
|
||||
}
|
||||
}
|
||||
|
||||
/// 银行卡信息响应实体,包裹当前银行卡资料。
|
||||
struct BankCardInfoResponse: Decodable, Equatable {
|
||||
let bankCard: WalletBankCardInfo?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case bankCard = "bank_card"
|
||||
}
|
||||
}
|
||||
|
||||
/// 钱包银行卡实体,表示银行卡资料和审核状态。
|
||||
struct WalletBankCardInfo: Decodable, Equatable, Identifiable {
|
||||
var id: String { cardNumber }
|
||||
let realName: String
|
||||
let bankName: String
|
||||
let branchName: String
|
||||
let cardNumber: String
|
||||
let frontUrl: String?
|
||||
let backUrl: String?
|
||||
let provinceCode: String?
|
||||
let cityCode: String?
|
||||
let rejectReason: String?
|
||||
let auditStatus: Int
|
||||
let auditStatusLabel: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case realName = "real_name"
|
||||
case bankName = "bank_name"
|
||||
case branchName = "branch_name"
|
||||
case cardNumber = "card_number"
|
||||
case frontUrl = "front_url"
|
||||
case backUrl = "back_url"
|
||||
case provinceCode = "province_code"
|
||||
case cityCode = "city_code"
|
||||
case rejectReason = "reject_reason"
|
||||
case auditStatus = "audit_status"
|
||||
case auditStatusLabel = "audit_status_label"
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容审核状态字段类型不稳定。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
realName = try container.decodeLossyString(forKey: .realName)
|
||||
bankName = try container.decodeLossyString(forKey: .bankName)
|
||||
branchName = try container.decodeLossyString(forKey: .branchName)
|
||||
cardNumber = try container.decodeLossyString(forKey: .cardNumber)
|
||||
frontUrl = try container.decodeIfPresent(String.self, forKey: .frontUrl)
|
||||
backUrl = try container.decodeIfPresent(String.self, forKey: .backUrl)
|
||||
provinceCode = try container.decodeIfPresent(String.self, forKey: .provinceCode)
|
||||
cityCode = try container.decodeIfPresent(String.self, forKey: .cityCode)
|
||||
rejectReason = try container.decodeIfPresent(String.self, forKey: .rejectReason)
|
||||
auditStatus = try container.decodeLossyInt(forKey: .auditStatus) ?? 0
|
||||
auditStatusLabel = try container.decodeLossyString(forKey: .auditStatusLabel)
|
||||
}
|
||||
}
|
||||
|
||||
/// 银行列表响应实体,表示可选择银行名称。
|
||||
struct BankListResponse: Decodable, Equatable {
|
||||
let banks: [String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case banks
|
||||
}
|
||||
|
||||
/// 创建银行列表响应实体,主要用于测试替身。
|
||||
init(banks: [String] = []) {
|
||||
self.banks = banks
|
||||
}
|
||||
}
|
||||
|
||||
/// 省市区节点实体,表示提现银行卡设置中的地区树。
|
||||
struct AreaNode: Decodable, Equatable, Identifiable {
|
||||
let id: String
|
||||
let code: String
|
||||
let name: String
|
||||
let children: [AreaNode]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case code
|
||||
case name
|
||||
case children
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容 id/code 类型不稳定。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
code = try container.decodeLossyString(forKey: .code)
|
||||
let rawId = try container.decodeLossyString(forKey: .id).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
id = rawId.isEmpty ? code : rawId
|
||||
name = try container.decodeLossyString(forKey: .name)
|
||||
children = try container.decodeIfPresent([AreaNode].self, forKey: .children) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 提现信息响应实体,表示提现上限、手机号和银行卡摘要。
|
||||
struct WithdrawInfoResponse: Decodable, Equatable {
|
||||
let amountWithdrawable: String
|
||||
let minWithdrawAmount: String
|
||||
let maxSingleWithdrawAmount: String
|
||||
let maxDailyWithdrawAmount: String
|
||||
let userPhone: String
|
||||
let bankCard: WithdrawBankCardInfo
|
||||
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 withdrawInfo = "withdraw_info"
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容金额字段类型不稳定。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
amountWithdrawable = try container.decodeLossyString(forKey: .amountWithdrawable)
|
||||
minWithdrawAmount = try container.decodeLossyString(forKey: .minWithdrawAmount)
|
||||
maxSingleWithdrawAmount = try container.decodeLossyString(forKey: .maxSingleWithdrawAmount)
|
||||
maxDailyWithdrawAmount = try container.decodeLossyString(forKey: .maxDailyWithdrawAmount)
|
||||
userPhone = try container.decodeLossyString(forKey: .userPhone)
|
||||
bankCard = try container.decodeIfPresent(WithdrawBankCardInfo.self, forKey: .bankCard) ?? WithdrawBankCardInfo()
|
||||
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.decodeLossyString(forKey: .realName)
|
||||
bankName = try container.decodeLossyString(forKey: .bankName)
|
||||
cardNumber = try container.decodeLossyString(forKey: .cardNumber)
|
||||
}
|
||||
}
|
||||
|
||||
/// 提现申请请求实体,表示金额和短信验证码。
|
||||
struct WithdrawApplyRequest: Encodable {
|
||||
let amount: String
|
||||
let smsCode: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case amount
|
||||
case smsCode = "sms_code"
|
||||
}
|
||||
}
|
||||
|
||||
/// 银行卡更新请求实体,表示银行卡资料、证件图片 URL 和验证码。
|
||||
struct UpdateBankInfoRequest: Encodable, Equatable {
|
||||
let realName: String
|
||||
let cardNumber: String
|
||||
let bankName: String
|
||||
let branchName: String
|
||||
let frontUrl: String
|
||||
let backUrl: String
|
||||
let provinceCode: String
|
||||
let cityCode: String
|
||||
let smsVerifyCode: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case realName = "real_name"
|
||||
case cardNumber = "card_number"
|
||||
case bankName = "bank_name"
|
||||
case branchName = "branch_name"
|
||||
case frontUrl = "front_url"
|
||||
case backUrl = "back_url"
|
||||
case provinceCode = "province_code"
|
||||
case cityCode = "city_code"
|
||||
case smsVerifyCode = "sms_verify_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.decodeLossyInt(forKey: .totalPoints) ?? 0
|
||||
availablePoints = try container.decodeLossyInt(forKey: .availablePoints) ?? 0
|
||||
withdrawnPoints = try container.decodeLossyInt(forKey: .withdrawnPoints) ?? 0
|
||||
pendingPoints = try container.decodeLossyInt(forKey: .pendingPoints) ?? 0
|
||||
time = try container.decodeLossyString(forKey: .time)
|
||||
}
|
||||
}
|
||||
|
||||
/// 积分兑换申请请求实体。
|
||||
struct PointWithdrawApplyRequest: Encodable {
|
||||
let points: Int
|
||||
let remark: String
|
||||
}
|
||||
|
||||
/// 积分兑换列表请求实体。
|
||||
struct PointWithdrawListRequest: Encodable {
|
||||
let status: Int?
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case status
|
||||
case page
|
||||
case pageSize = "page_size"
|
||||
}
|
||||
}
|
||||
|
||||
/// 积分兑换列表响应实体。
|
||||
struct PointWithdrawListResponse: Decodable, Equatable {
|
||||
let total: Int
|
||||
let list: [PointWithdrawItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case total
|
||||
case list
|
||||
}
|
||||
|
||||
/// 创建积分兑换列表响应实体,主要用于测试替身。
|
||||
init(total: Int = 0, list: [PointWithdrawItem] = []) {
|
||||
self.total = total
|
||||
self.list = list
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容字段缺失和类型不稳定。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
total = try container.decodeLossyInt(forKey: .total) ?? 0
|
||||
list = try container.decodeIfPresent([PointWithdrawItem].self, forKey: .list) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 积分兑换记录实体。
|
||||
struct PointWithdrawItem: Decodable, Equatable, Identifiable {
|
||||
let id: Int
|
||||
let points: Int
|
||||
let amount: Double
|
||||
let status: Int
|
||||
let createdAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case points
|
||||
case amount
|
||||
case status
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容字段类型不稳定。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
points = try container.decodeLossyInt(forKey: .points) ?? 0
|
||||
amount = try container.decodeLossyDouble(forKey: .amount) ?? 0
|
||||
status = try container.decodeLossyInt(forKey: .status) ?? 0
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
/// 钱包本地路由实体,表示钱包内二级页面。
|
||||
enum WalletRoute: Hashable, Identifiable {
|
||||
case withdrawApply
|
||||
case bankCardSettings
|
||||
case pointsRedemption
|
||||
case realNameAuth
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .withdrawApply: "withdrawApply"
|
||||
case .bankCardSettings: "bankCardSettings"
|
||||
case .pointsRedemption: "pointsRedemption"
|
||||
case .realNameAuth: "realNameAuth"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 提现资格决策实体,表示点击提现后的下一步页面或提示。
|
||||
enum WalletWithdrawDecision: Equatable {
|
||||
case route(WalletRoute)
|
||||
case message(String)
|
||||
}
|
||||
|
||||
/// 钱包筛选区间实体,表示收益明细查询时间范围。
|
||||
enum WalletDateFilter: String, CaseIterable, Identifiable {
|
||||
case last7
|
||||
case last30
|
||||
case last180
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// 当前筛选区间的展示标题。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .last7: "近7日"
|
||||
case .last30: "近30日"
|
||||
case .last180: "近180日"
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据当前日期计算开始和结束日期。
|
||||
func range(today: Date = Date(), calendar: Calendar = .current) -> (start: String, end: String) {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = calendar
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
let days: Int
|
||||
switch self {
|
||||
case .last7:
|
||||
days = 6
|
||||
case .last30:
|
||||
days = 29
|
||||
case .last180:
|
||||
days = 179
|
||||
}
|
||||
let startDate = calendar.date(byAdding: .day, value: -days, to: today) ?? today
|
||||
return (formatter.string(from: startDate), formatter.string(from: today))
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 将字符串、数字或空值宽松解码为字符串。
|
||||
func decodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try decodeIfPresent(Int.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try decodeIfPresent(Double.self, forKey: key) {
|
||||
return value.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(value)) : String(value)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 将字符串或数字宽松解码为整数。
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = Double(text) {
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
if let value = try decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 将字符串或数字宽松解码为小数。
|
||||
func decodeLossyDouble(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) {
|
||||
return Double(value.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,132 @@
|
||||
//
|
||||
// WalletViewControllers.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 钱包首页。
|
||||
final class WalletViewController: ModuleTableViewController {
|
||||
private let viewModel = WalletViewModel()
|
||||
private let summaryLabel = UILabel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "我的钱包"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "提现",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(openWithdraw)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
setupHeader()
|
||||
wireViewModel(viewModel) { [weak self] in self?.updateSummary() }
|
||||
}
|
||||
|
||||
private func setupHeader() {
|
||||
summaryLabel.numberOfLines = 0
|
||||
summaryLabel.font = .systemFont(ofSize: 14)
|
||||
summaryLabel.textAlignment = .center
|
||||
summaryLabel.textColor = AppDesign.textSecondary
|
||||
summaryLabel.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 72)
|
||||
tableView.tableHeaderView = summaryLabel
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int {
|
||||
switch viewModel.selectedTab {
|
||||
case .earnings:
|
||||
return viewModel.earningsGroups.flatMap(\.items).count
|
||||
case .withdraws:
|
||||
return viewModel.withdrawRecords.count
|
||||
}
|
||||
}
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
switch viewModel.selectedTab {
|
||||
case .earnings:
|
||||
let items = viewModel.earningsGroups.flatMap(\.items)
|
||||
let item = items[indexPath.row]
|
||||
cell.configure(title: item.typeLabel, subtitle: item.amount, detail: item.createdAt)
|
||||
case .withdraws:
|
||||
let record = viewModel.withdrawRecords[indexPath.row]
|
||||
cell.configure(title: "¥\(record.amount)", subtitle: record.statusLabel, detail: record.createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.loadInitial(api: services.walletAPI, staffId: services.staffId)
|
||||
updateSummary()
|
||||
}
|
||||
|
||||
private func updateSummary() {
|
||||
summaryLabel.text = "\(viewModel.withdrawableText) · \(viewModel.totalAmountText)"
|
||||
navigationItem.rightBarButtonItem?.title = viewModel.selectedTab == .earnings ? "提现记录" : "收益明细"
|
||||
}
|
||||
|
||||
@objc private func openWithdraw() {
|
||||
navigationController?.pushViewController(WalletWithdrawViewController(), animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension WalletViewModel: ViewModelBindable {}
|
||||
|
||||
/// 钱包提现页。
|
||||
final class WalletWithdrawViewController: ModuleTableViewController {
|
||||
private let viewModel = WithdrawApplyViewModel()
|
||||
private let amountField = UITextField()
|
||||
private let smsField = UITextField()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "申请提现"
|
||||
navigationItem.rightBarButtonItems = [
|
||||
UIBarButtonItem(title: "提交", style: .done, target: self, action: #selector(submit)),
|
||||
UIBarButtonItem(title: "验证码", style: .plain, target: self, action: #selector(sendSms))
|
||||
]
|
||||
super.viewDidLoad()
|
||||
amountField.placeholder = "提现金额"
|
||||
amountField.borderStyle = .roundedRect
|
||||
amountField.keyboardType = .decimalPad
|
||||
smsField.placeholder = "短信验证码"
|
||||
smsField.borderStyle = .roundedRect
|
||||
let stack = UIStackView(arrangedSubviews: [amountField, smsField])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
stack.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 96)
|
||||
stack.layoutMargins = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
|
||||
stack.isLayoutMarginsRelativeArrangement = true
|
||||
tableView.tableHeaderView = stack
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { 1 }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let amount = viewModel.info?.amountWithdrawable ?? "0"
|
||||
cell.configure(title: "可提现", subtitle: "¥ \(amount)")
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.load(api: services.walletAPI)
|
||||
}
|
||||
|
||||
@objc private func sendSms() {
|
||||
Task { await viewModel.sendSms(api: services.walletAPI) }
|
||||
}
|
||||
|
||||
@objc private func submit() {
|
||||
viewModel.amountText = amountField.text ?? ""
|
||||
viewModel.smsCode = smsField.text ?? ""
|
||||
Task {
|
||||
if await viewModel.submit(api: services.walletAPI) {
|
||||
services.toastCenter.show("提现申请已提交")
|
||||
navigationController?.popViewController(animated: true)
|
||||
} else if let message = viewModel.errorMessage {
|
||||
services.toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WithdrawApplyViewModel: ViewModelBindable {}
|
||||
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)
|
||||
}
|
||||
}
|
||||
31
suixinkan_ios/Features/Wallet/Wallet.md
Normal file
31
suixinkan_ios/Features/Wallet/Wallet.md
Normal file
@ -0,0 +1,31 @@
|
||||
# 钱包模块
|
||||
|
||||
## 模块职责
|
||||
|
||||
`Features/Wallet` 承接首页 `wallet` 权限入口,负责个人钱包首页、收益明细、提现记录、提现申请、银行卡设置和积分兑换。
|
||||
|
||||
## 代码结构
|
||||
|
||||
- `WalletAPI`:封装钱包、提现、银行卡、地区、积分相关接口。
|
||||
- `WalletViewModel`:管理钱包汇总、收益明细分页、提现记录分页和提现资格判断。
|
||||
- `WithdrawApplyViewModel`:管理提现信息、金额校验、短信验证码和提现提交。
|
||||
- `WithdrawalSettingsViewModel`:管理银行卡资料、图片上传和银行卡更新提交。
|
||||
- `PointsRedemptionViewModel`:管理积分概览、兑换记录和兑换申请。
|
||||
- `WalletView`:钱包首页和钱包内部二级路由入口。
|
||||
|
||||
## 提现资格流
|
||||
|
||||
点击提现后先查询实名认证:
|
||||
|
||||
1. 未提交或驳回:进入已迁移的 `RealNameAuthView`。
|
||||
2. 审核中:提示等待实名认证审核通过。
|
||||
3. 实名认证通过:查询银行卡资料。
|
||||
4. 无银行卡或银行卡驳回:进入 `WithdrawalSettingsView`。
|
||||
5. 银行卡审核中:提示等待审核。
|
||||
6. 银行卡通过:进入 `WithdrawApplyView`。
|
||||
|
||||
## 上传与缓存边界
|
||||
|
||||
银行卡正反面照片使用 `PhotosPicker` 选择后,通过 `OSSUploadService.uploadBankCardImage` 上传到 `bank_card/yyyyMMdd/scenicId/...` 路径,再把最终 URL 提交给服务端。
|
||||
|
||||
钱包数据只保存在各 ViewModel 内存中。短信验证码、OSS STS、提现表单、银行卡图片 Data 和积分兑换表单均不落盘。
|
||||
Reference in New Issue
Block a user