Add Payment and Wallet modules with home routing integration.
Introduce real payment collection and wallet screens to replace home menu placeholders, wire APIs through RootView, and support bank card OSS uploads plus QR code saving to the photo library. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
204
suixinkan/Features/Wallet/API/WalletAPI.swift
Normal file
204
suixinkan/Features/Wallet/API/WalletAPI.swift
Normal file
@ -0,0 +1,204 @@
|
||||
//
|
||||
// WalletAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 钱包模块服务协议,定义钱包、提现、银行卡和积分接口能力。
|
||||
@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
|
||||
@Observable
|
||||
/// 钱包 API,封装个人钱包、提现、银行卡和积分兑换网络请求。
|
||||
final class WalletAPI {
|
||||
@ObservationIgnored 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 {}
|
||||
604
suixinkan/Features/Wallet/Models/WalletModels.swift
Normal file
604
suixinkan/Features/Wallet/Models/WalletModels.swift
Normal file
@ -0,0 +1,604 @@
|
||||
//
|
||||
// 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(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
|
||||
}
|
||||
}
|
||||
538
suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift
Normal file
538
suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift
Normal file
@ -0,0 +1,538 @@
|
||||
//
|
||||
// WalletViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
/// 钱包实名认证服务协议,抽象实名认证状态读取能力以便测试替换。
|
||||
protocol WalletRealNameServing {
|
||||
/// 获取当前登录用户实名认证状态。
|
||||
func realNameInfo() async throws -> RealNameInfoResponse
|
||||
}
|
||||
|
||||
extension ProfileAPI: WalletRealNameServing {}
|
||||
|
||||
/// 钱包首页明细 Tab,区分收益明细和提现记录。
|
||||
enum WalletLedgerTab: String, CaseIterable, Identifiable {
|
||||
case earnings
|
||||
case withdraws
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// Tab 展示标题。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .earnings: "收益明细"
|
||||
case .withdraws: "提现记录"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 钱包首页 ViewModel,管理钱包汇总、收益明细、提现记录和提现资格流。
|
||||
final class WalletViewModel {
|
||||
var summary: WalletSummaryResponse?
|
||||
var pointsOverview: PointOverviewResponse?
|
||||
var selectedTab: WalletLedgerTab = .earnings
|
||||
var selectedFilter: WalletDateFilter = .last30
|
||||
var earningsGroups: [WalletEarningDetailGroup] = []
|
||||
var withdrawRecords: [WalletWithdrawRecord] = []
|
||||
var isLoadingSummary = false
|
||||
var isLoadingList = false
|
||||
var isCheckingWithdraw = false
|
||||
var errorMessage: String?
|
||||
|
||||
private let pageSize = 20
|
||||
private var earningsPage = 1
|
||||
private var earningsTotal = 0
|
||||
private var withdrawPage = 1
|
||||
private var withdrawTotal = 0
|
||||
|
||||
/// 钱包可提现金额展示文本。
|
||||
var withdrawableText: String {
|
||||
"¥ \(Self.moneyText(summary?.amountWithdrawable))"
|
||||
}
|
||||
|
||||
/// 钱包累计金额展示文本。
|
||||
var totalAmountText: String {
|
||||
"¥ \(Self.moneyText(summary?.amountTotal))"
|
||||
}
|
||||
|
||||
/// 当前余额展示文本。
|
||||
var currentBalanceText: String {
|
||||
"¥ \(Self.moneyText(summary?.amountCurrentBalance))"
|
||||
}
|
||||
|
||||
/// 是否还能继续加载收益明细。
|
||||
var canLoadMoreEarnings: Bool {
|
||||
earningsGroups.flatMap(\.items).count < earningsTotal
|
||||
}
|
||||
|
||||
/// 是否还能继续加载提现记录。
|
||||
var canLoadMoreWithdraws: Bool {
|
||||
withdrawRecords.count < withdrawTotal
|
||||
}
|
||||
|
||||
/// 首次加载钱包首页数据。
|
||||
func loadInitial(api: WalletServing, staffId: Int?) async {
|
||||
isLoadingSummary = true
|
||||
errorMessage = nil
|
||||
async let summaryResult = api.walletSummary(type: 0)
|
||||
async let pointsResult: PointOverviewResponse? = {
|
||||
guard let staffId else { return nil }
|
||||
return try? await api.pointOverview(staffId: staffId)
|
||||
}()
|
||||
|
||||
do {
|
||||
summary = try await summaryResult
|
||||
pointsOverview = await pointsResult
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
isLoadingSummary = false
|
||||
await reloadList(api: api)
|
||||
}
|
||||
|
||||
/// 按当前 Tab 和筛选条件刷新列表。
|
||||
func reloadList(api: WalletServing) async {
|
||||
switch selectedTab {
|
||||
case .earnings:
|
||||
earningsPage = 1
|
||||
await loadEarnings(api: api, reset: true)
|
||||
case .withdraws:
|
||||
withdrawPage = 1
|
||||
await loadWithdraws(api: api, reset: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换钱包明细 Tab 并刷新对应列表。
|
||||
func selectTab(_ tab: WalletLedgerTab, api: WalletServing) async {
|
||||
guard selectedTab != tab else { return }
|
||||
selectedTab = tab
|
||||
await reloadList(api: api)
|
||||
}
|
||||
|
||||
/// 切换收益明细时间筛选并重置分页。
|
||||
func selectFilter(_ filter: WalletDateFilter, api: WalletServing) async {
|
||||
guard selectedFilter != filter else { return }
|
||||
selectedFilter = filter
|
||||
if selectedTab == .earnings {
|
||||
await reloadList(api: api)
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载下一页当前列表。
|
||||
func loadMore(api: WalletServing) async {
|
||||
switch selectedTab {
|
||||
case .earnings where canLoadMoreEarnings:
|
||||
earningsPage += 1
|
||||
await loadEarnings(api: api, reset: false)
|
||||
case .withdraws where canLoadMoreWithdraws:
|
||||
withdrawPage += 1
|
||||
await loadWithdraws(api: api, reset: false)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据实名认证和银行卡状态决定提现下一步。
|
||||
func resolveWithdrawDecision(profileAPI: WalletRealNameServing, walletAPI: WalletServing) async -> WalletWithdrawDecision {
|
||||
isCheckingWithdraw = true
|
||||
defer { isCheckingWithdraw = false }
|
||||
|
||||
do {
|
||||
let realName = try await profileAPI.realNameInfo().realNameInfo
|
||||
guard let realName else {
|
||||
return .route(.realNameAuth)
|
||||
}
|
||||
switch realName.auditStatus {
|
||||
case 2:
|
||||
let bankCard = try await walletAPI.bankCardInfo().bankCard
|
||||
guard let bankCard else {
|
||||
return .route(.bankCardSettings)
|
||||
}
|
||||
if bankCard.auditStatus == 2 {
|
||||
return .route(.withdrawApply)
|
||||
}
|
||||
if bankCard.auditStatus == 3 {
|
||||
return .route(.bankCardSettings)
|
||||
}
|
||||
return .message("银行卡审核中,请审核通过后再试")
|
||||
case 3:
|
||||
return .route(.realNameAuth)
|
||||
default:
|
||||
return .message("实名认证审核中,请审核通过后再试")
|
||||
}
|
||||
} catch {
|
||||
return .message(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载收益明细分页。
|
||||
private func loadEarnings(api: WalletServing, reset: Bool) async {
|
||||
isLoadingList = true
|
||||
errorMessage = nil
|
||||
defer { isLoadingList = false }
|
||||
|
||||
let range = selectedFilter.range()
|
||||
do {
|
||||
let response = try await api.walletEarningDetail(
|
||||
startDate: range.start,
|
||||
endDate: range.end,
|
||||
page: earningsPage,
|
||||
pageSize: pageSize
|
||||
)
|
||||
earningsTotal = response.total
|
||||
earningsGroups = reset ? response.list : earningsGroups + response.list
|
||||
} catch {
|
||||
if !reset { earningsPage = max(1, earningsPage - 1) }
|
||||
if reset { earningsGroups = [] }
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载提现记录分页。
|
||||
private func loadWithdraws(api: WalletServing, reset: Bool) async {
|
||||
isLoadingList = true
|
||||
errorMessage = nil
|
||||
defer { isLoadingList = false }
|
||||
|
||||
do {
|
||||
let response = try await api.walletWithdrawList(page: withdrawPage, pageSize: pageSize)
|
||||
withdrawTotal = response.total
|
||||
withdrawRecords = reset ? response.item : withdrawRecords + response.item
|
||||
} catch {
|
||||
if !reset { withdrawPage = max(1, withdrawPage - 1) }
|
||||
if reset { withdrawRecords = [] }
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 规范金额展示,空值按 0.00 处理。
|
||||
static func moneyText(_ rawValue: String?) -> String {
|
||||
let text = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard let decimal = Decimal(string: text), decimal > 0 else {
|
||||
return "0.00"
|
||||
}
|
||||
return NSDecimalNumber(decimal: decimal).stringValue
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 提现申请 ViewModel,管理提现信息、金额校验、短信验证码和提交。
|
||||
final class WithdrawApplyViewModel {
|
||||
var info: WithdrawInfoResponse?
|
||||
var amountText = ""
|
||||
var smsCode = ""
|
||||
var isLoading = false
|
||||
var isSubmitting = false
|
||||
var smsCountdown = 0
|
||||
var errorMessage: String?
|
||||
|
||||
/// 加载提现申请所需信息。
|
||||
func load(api: WalletServing) async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
info = try await api.withdrawInfo()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送提现短信验证码。
|
||||
func sendSms(api: WalletServing) async {
|
||||
guard smsCountdown == 0 else { return }
|
||||
do {
|
||||
try await api.withdrawSendSms()
|
||||
smsCountdown = 60
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交提现申请,成功后清空表单。
|
||||
func submit(api: WalletServing) async -> Bool {
|
||||
guard !isSubmitting else { return false }
|
||||
guard let amount = PaymentCollectionViewModel.normalizedMoney(amountText) else {
|
||||
errorMessage = "请输入有效提现金额"
|
||||
return false
|
||||
}
|
||||
guard let info else {
|
||||
errorMessage = "提现信息未加载"
|
||||
return false
|
||||
}
|
||||
guard (Decimal(string: amount) ?? 0) <= (Decimal(string: info.amountWithdrawable) ?? 0) else {
|
||||
errorMessage = "提现金额不能超过可提现金额"
|
||||
return false
|
||||
}
|
||||
guard !smsCode.walletTrimmed.isEmpty else {
|
||||
errorMessage = "请输入短信验证码"
|
||||
return false
|
||||
}
|
||||
|
||||
isSubmitting = true
|
||||
defer { isSubmitting = false }
|
||||
do {
|
||||
try await api.withdrawApply(amount: amount, smsCode: smsCode.walletTrimmed)
|
||||
amountText = ""
|
||||
smsCode = ""
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 银行卡设置 ViewModel,管理银行卡表单、图片上传、短信验证码和提交。
|
||||
final class WithdrawalSettingsViewModel {
|
||||
var bankCard: WalletBankCardInfo?
|
||||
var banks: [String] = []
|
||||
var areas: [AreaNode] = []
|
||||
var realName = ""
|
||||
var cardNumber = ""
|
||||
var bankName = ""
|
||||
var branchName = ""
|
||||
var provinceCode = ""
|
||||
var cityCode = ""
|
||||
var smsCode = ""
|
||||
var frontImageData: Data?
|
||||
var backImageData: Data?
|
||||
var frontImageURL = ""
|
||||
var backImageURL = ""
|
||||
var uploadProgress = 0
|
||||
var isLoading = false
|
||||
var isSubmitting = false
|
||||
var smsCountdown = 0
|
||||
var errorMessage: String?
|
||||
|
||||
/// 加载银行卡设置需要的银行、地区和已提交资料。
|
||||
func load(api: WalletServing) async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
async let bankCardResult = api.bankCardInfo()
|
||||
async let bankListResult = api.bankList()
|
||||
async let areaResult = api.areas()
|
||||
do {
|
||||
let (cardResponse, bankResponse, areaResponse) = try await (bankCardResult, bankListResult, areaResult)
|
||||
bankCard = cardResponse.bankCard
|
||||
banks = bankResponse.banks
|
||||
areas = areaResponse
|
||||
apply(cardResponse.bankCard)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送银行卡设置短信验证码。
|
||||
func sendSms(api: WalletServing) async {
|
||||
guard smsCountdown == 0 else { return }
|
||||
do {
|
||||
try await api.bankCardVerifyCode()
|
||||
smsCountdown = 60
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传图片并提交银行卡资料。
|
||||
func submit(api: WalletServing, uploader: OSSUploadServing, scenicId: Int?) async -> Bool {
|
||||
guard !isSubmitting else { return false }
|
||||
guard let scenicId else {
|
||||
errorMessage = "请先选择景区"
|
||||
return false
|
||||
}
|
||||
guard validateForm() else { return false }
|
||||
|
||||
isSubmitting = true
|
||||
uploadProgress = 0
|
||||
defer { isSubmitting = false }
|
||||
|
||||
do {
|
||||
let frontURL = try await resolvedImageURL(
|
||||
currentURL: frontImageURL,
|
||||
data: frontImageData,
|
||||
fileName: "bank_card_front.jpg",
|
||||
uploader: uploader,
|
||||
scenicId: scenicId
|
||||
)
|
||||
let backURL = try await resolvedImageURL(
|
||||
currentURL: backImageURL,
|
||||
data: backImageData,
|
||||
fileName: "bank_card_back.jpg",
|
||||
uploader: uploader,
|
||||
scenicId: scenicId
|
||||
)
|
||||
let request = UpdateBankInfoRequest(
|
||||
realName: realName.walletTrimmed,
|
||||
cardNumber: cardNumber.walletTrimmed,
|
||||
bankName: bankName.walletTrimmed,
|
||||
branchName: branchName.walletTrimmed,
|
||||
frontUrl: frontURL,
|
||||
backUrl: backURL,
|
||||
provinceCode: provinceCode.walletTrimmed,
|
||||
cityCode: cityCode.walletTrimmed,
|
||||
smsVerifyCode: smsCode.walletTrimmed
|
||||
)
|
||||
try await api.updateBankInfo(request)
|
||||
frontImageURL = frontURL
|
||||
backImageURL = backURL
|
||||
frontImageData = nil
|
||||
backImageData = nil
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 将已有银行卡资料回填到表单。
|
||||
private func apply(_ info: WalletBankCardInfo?) {
|
||||
guard let info else { return }
|
||||
realName = info.realName
|
||||
cardNumber = info.cardNumber
|
||||
bankName = info.bankName
|
||||
branchName = info.branchName
|
||||
provinceCode = info.provinceCode ?? ""
|
||||
cityCode = info.cityCode ?? ""
|
||||
frontImageURL = info.frontUrl ?? ""
|
||||
backImageURL = info.backUrl ?? ""
|
||||
}
|
||||
|
||||
/// 校验银行卡表单必填项。
|
||||
private func validateForm() -> Bool {
|
||||
if realName.walletTrimmed.isEmpty { errorMessage = "请输入持卡人姓名"; return false }
|
||||
if cardNumber.walletTrimmed.isEmpty { errorMessage = "请输入银行卡号"; return false }
|
||||
if bankName.walletTrimmed.isEmpty { errorMessage = "请选择开户银行"; return false }
|
||||
if branchName.walletTrimmed.isEmpty { errorMessage = "请输入开户支行"; return false }
|
||||
if provinceCode.walletTrimmed.isEmpty || cityCode.walletTrimmed.isEmpty { errorMessage = "请选择开户地区"; return false }
|
||||
if frontImageURL.walletTrimmed.isEmpty && frontImageData == nil { errorMessage = "请选择银行卡正面照片"; return false }
|
||||
if backImageURL.walletTrimmed.isEmpty && backImageData == nil { errorMessage = "请选择银行卡反面照片"; return false }
|
||||
if smsCode.walletTrimmed.isEmpty { errorMessage = "请输入短信验证码"; return false }
|
||||
return true
|
||||
}
|
||||
|
||||
/// 返回最终图片 URL,本地选择的新图会先上传 OSS。
|
||||
private func resolvedImageURL(
|
||||
currentURL: String,
|
||||
data: Data?,
|
||||
fileName: String,
|
||||
uploader: OSSUploadServing,
|
||||
scenicId: Int
|
||||
) async throws -> String {
|
||||
guard let data else { return currentURL }
|
||||
return try await uploader.uploadBankCardImage(data: data, fileName: fileName, scenicId: scenicId) { [weak self] progress in
|
||||
self?.uploadProgress = progress
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 积分兑换 ViewModel,管理积分概览、兑换记录、兑换金额校验和提交。
|
||||
final class PointsRedemptionViewModel {
|
||||
var overview = PointOverviewResponse()
|
||||
var records: [PointWithdrawItem] = []
|
||||
var pointsText = ""
|
||||
var isLoading = false
|
||||
var isSubmitting = false
|
||||
var errorMessage: String?
|
||||
|
||||
private let pageSize = 20
|
||||
private var page = 1
|
||||
private var total = 0
|
||||
|
||||
/// 是否还能继续加载兑换记录。
|
||||
var canLoadMore: Bool {
|
||||
records.count < total
|
||||
}
|
||||
|
||||
/// 加载积分概览和兑换记录。
|
||||
func load(api: WalletServing, staffId: Int?) async {
|
||||
guard let staffId else {
|
||||
errorMessage = "缺少账号信息"
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
page = 1
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
async let overviewResult = api.pointOverview(staffId: staffId)
|
||||
async let listResult = api.pointWithdrawList(status: nil, page: page, pageSize: pageSize)
|
||||
overview = try await overviewResult
|
||||
let list = try await listResult
|
||||
total = list.total
|
||||
records = list.list
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载下一页积分兑换记录。
|
||||
func loadMore(api: WalletServing) async {
|
||||
guard canLoadMore else { return }
|
||||
page += 1
|
||||
do {
|
||||
let response = try await api.pointWithdrawList(status: nil, page: page, pageSize: pageSize)
|
||||
total = response.total
|
||||
records += response.list
|
||||
} catch {
|
||||
page = max(1, page - 1)
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 将可兑换积分全部填入表单。
|
||||
func fillAllPoints() {
|
||||
pointsText = String(overview.withdrawnPoints)
|
||||
}
|
||||
|
||||
/// 提交积分兑换申请,成功后刷新概览和列表。
|
||||
func submit(api: WalletServing, staffId: Int?) async -> Bool {
|
||||
guard !isSubmitting else { return false }
|
||||
guard let points = Int(pointsText.walletTrimmed), points > 0 else {
|
||||
errorMessage = "请输入有效兑换积分"
|
||||
return false
|
||||
}
|
||||
guard points <= overview.withdrawnPoints else {
|
||||
errorMessage = "兑换积分不能超过可兑换积分"
|
||||
return false
|
||||
}
|
||||
guard let staffId else {
|
||||
errorMessage = "缺少账号信息"
|
||||
return false
|
||||
}
|
||||
|
||||
isSubmitting = true
|
||||
defer { isSubmitting = false }
|
||||
do {
|
||||
try await api.pointWithdrawApply(points: points, remark: "积分提现申请")
|
||||
pointsText = ""
|
||||
await load(api: api, staffId: staffId)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
/// 钱包模块内部使用的去空白文本,避免依赖其他文件的 fileprivate 扩展。
|
||||
var walletTrimmed: String {
|
||||
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
531
suixinkan/Features/Wallet/Views/WalletView.swift
Normal file
531
suixinkan/Features/Wallet/Views/WalletView.swift
Normal file
@ -0,0 +1,531 @@
|
||||
//
|
||||
// WalletView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
|
||||
/// 钱包首页,展示钱包汇总、收益明细、提现记录和钱包二级入口。
|
||||
struct WalletView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(WalletAPI.self) private var walletAPI
|
||||
@Environment(ProfileAPI.self) private var profileAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
|
||||
@State private var viewModel = WalletViewModel()
|
||||
@State private var route: WalletRoute?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
summaryCard
|
||||
walletActions
|
||||
ledgerControls
|
||||
ledgerList
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||
.padding(.vertical, AppMetrics.Spacing.pageVertical)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("我的钱包")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationDestination(item: $route) { route in
|
||||
switch route {
|
||||
case .withdrawApply:
|
||||
WithdrawApplyView()
|
||||
case .bankCardSettings:
|
||||
WithdrawalSettingsView()
|
||||
case .pointsRedemption:
|
||||
PointsRedemptionView()
|
||||
case .realNameAuth:
|
||||
RealNameAuthView()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadInitial(api: walletAPI, staffId: staffId)
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.loadInitial(api: walletAPI, staffId: staffId)
|
||||
}
|
||||
.onChange(of: viewModel.errorMessage) { _, message in
|
||||
if let message {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前用户 staffId,优先使用账号快照里的 userId。
|
||||
private var staffId: Int? {
|
||||
Int(accountContext.profile?.userId ?? "")
|
||||
}
|
||||
|
||||
/// 钱包汇总卡片。
|
||||
private var summaryCard: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
||||
Text("可提现金额")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(.white.opacity(0.86))
|
||||
Text(viewModel.withdrawableText)
|
||||
.font(.system(size: AppMetrics.FontSize.largeTitle, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
Spacer()
|
||||
if viewModel.isLoadingSummary {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: AppMetrics.Spacing.medium) {
|
||||
walletSummaryItem(title: "累计金额", value: viewModel.totalAmountText)
|
||||
walletSummaryItem(title: "当前余额", value: viewModel.currentBalanceText)
|
||||
walletSummaryItem(title: "可兑换积分", value: "\(viewModel.pointsOverview?.withdrawnPoints ?? 0)")
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.large)
|
||||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
/// 钱包快捷操作。
|
||||
private var walletActions: some View {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
walletActionButton(title: "提现", icon: "banknote") {
|
||||
Task {
|
||||
let decision = await viewModel.resolveWithdrawDecision(profileAPI: profileAPI, walletAPI: walletAPI)
|
||||
switch decision {
|
||||
case .route(let nextRoute):
|
||||
route = nextRoute
|
||||
case .message(let message):
|
||||
toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
walletActionButton(title: "银行卡", icon: "creditcard") {
|
||||
route = .bankCardSettings
|
||||
}
|
||||
walletActionButton(title: "积分兑换", icon: "giftcard") {
|
||||
route = .pointsRedemption
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 明细筛选控件。
|
||||
private var ledgerControls: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
Picker("明细类型", selection: Binding(
|
||||
get: { viewModel.selectedTab },
|
||||
set: { tab in Task { await viewModel.selectTab(tab, api: walletAPI) } }
|
||||
)) {
|
||||
ForEach(WalletLedgerTab.allCases) { tab in
|
||||
Text(tab.title).tag(tab)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
if viewModel.selectedTab == .earnings {
|
||||
HStack {
|
||||
ForEach(WalletDateFilter.allCases) { filter in
|
||||
Button {
|
||||
Task { await viewModel.selectFilter(filter, api: walletAPI) }
|
||||
} label: {
|
||||
Text(filter.title)
|
||||
.font(.system(size: AppMetrics.FontSize.footnote, weight: .medium))
|
||||
.foregroundStyle(viewModel.selectedFilter == filter ? .white : AppDesign.primary)
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.padding(.vertical, AppMetrics.Spacing.xSmall)
|
||||
.background(viewModel.selectedFilter == filter ? AppDesign.primary : AppDesign.primarySoft, in: Capsule())
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
/// 钱包明细列表。
|
||||
@ViewBuilder
|
||||
private var ledgerList: some View {
|
||||
if viewModel.selectedTab == .earnings {
|
||||
LazyVStack(spacing: AppMetrics.Spacing.small) {
|
||||
ForEach(viewModel.earningsGroups) { group in
|
||||
walletSection(title: group.date, subtitle: "¥\(group.dayAmount) / \(group.dayPoints)分") {
|
||||
ForEach(group.items) { item in
|
||||
ledgerRow(title: item.typeLabel.isEmpty ? "收益" : item.typeLabel, subtitle: item.createdAt, amount: "+¥\(item.amount)", extra: item.orderNumberSuffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
loadMoreButton(enabled: viewModel.canLoadMoreEarnings)
|
||||
}
|
||||
} else {
|
||||
LazyVStack(spacing: AppMetrics.Spacing.small) {
|
||||
ForEach(viewModel.withdrawRecords) { record in
|
||||
ledgerRow(title: record.statusLabel, subtitle: record.createdAt, amount: "-¥\(record.amount)", extra: record.expectedAt ?? "")
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
loadMoreButton(enabled: viewModel.canLoadMoreWithdraws)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 汇总子项。
|
||||
private func walletSummaryItem(title: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxxSmall) {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(.white.opacity(0.75))
|
||||
Text(value)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
/// 快捷操作按钮。
|
||||
private func walletActionButton(title: String, icon: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.footnote, weight: .medium))
|
||||
}
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(maxWidth: .infinity, minHeight: 76)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
/// 白色分组卡片。
|
||||
private func walletSection<Content: View>(title: String, subtitle: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
Spacer()
|
||||
Text(subtitle)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
content()
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
/// 明细行。
|
||||
private func ledgerRow(title: String, subtitle: String, amount: String, extra: String) -> some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxxSmall) {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Text([subtitle, extra].filter { !$0.isEmpty }.joined(separator: " · "))
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(amount)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(amount.hasPrefix("+") ? AppDesign.success : AppDesign.textPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载更多按钮。
|
||||
@ViewBuilder
|
||||
private func loadMoreButton(enabled: Bool) -> some View {
|
||||
if enabled {
|
||||
Button {
|
||||
Task { await viewModel.loadMore(api: walletAPI) }
|
||||
} label: {
|
||||
Text(viewModel.isLoadingList ? "加载中..." : "加载更多")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
|
||||
.frame(maxWidth: .infinity, minHeight: 44)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 提现申请页,输入金额和短信验证码后提交提现。
|
||||
struct WithdrawApplyView: View {
|
||||
@Environment(WalletAPI.self) private var walletAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var viewModel = WithdrawApplyViewModel()
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if let info = viewModel.info {
|
||||
Section("银行卡") {
|
||||
Text("\(info.bankCard.bankName) \(info.bankCard.cardNumber)")
|
||||
Text("可提现金额:¥\(WalletViewModel.moneyText(info.amountWithdrawable))")
|
||||
}
|
||||
}
|
||||
|
||||
Section("提现金额") {
|
||||
TextField("请输入提现金额", text: $viewModel.amountText)
|
||||
.keyboardType(.decimalPad)
|
||||
HStack {
|
||||
TextField("短信验证码", text: $viewModel.smsCode)
|
||||
.keyboardType(.numberPad)
|
||||
Button(viewModel.smsCountdown > 0 ? "\(viewModel.smsCountdown)s" : "获取验证码") {
|
||||
Task { await viewModel.sendSms(api: walletAPI) }
|
||||
}
|
||||
.disabled(viewModel.smsCountdown > 0)
|
||||
}
|
||||
}
|
||||
|
||||
Button(viewModel.isSubmitting ? "提交中..." : "提交提现申请") {
|
||||
Task {
|
||||
if await viewModel.submit(api: walletAPI) {
|
||||
toastCenter.show("提现申请已提交")
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isSubmitting)
|
||||
}
|
||||
.navigationTitle("提现申请")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await viewModel.load(api: walletAPI) }
|
||||
.onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in
|
||||
if viewModel.smsCountdown > 0 {
|
||||
viewModel.smsCountdown -= 1
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.errorMessage) { _, message in
|
||||
if let message {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 银行卡设置页,使用图片选择和 OSS 上传提交银行卡资料。
|
||||
struct WithdrawalSettingsView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(WalletAPI.self) private var walletAPI
|
||||
@Environment(OSSUploadService.self) private var ossUploadService
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var viewModel = WithdrawalSettingsViewModel()
|
||||
@State private var frontItem: PhotosPickerItem?
|
||||
@State private var backItem: PhotosPickerItem?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if let bankCard = viewModel.bankCard {
|
||||
Section("审核状态") {
|
||||
Text(bankCard.auditStatusLabel.isEmpty ? "待提交" : bankCard.auditStatusLabel)
|
||||
if let reason = bankCard.rejectReason, !reason.isEmpty {
|
||||
Text(reason).foregroundStyle(AppDesign.warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("基础信息") {
|
||||
TextField("持卡人姓名", text: $viewModel.realName)
|
||||
TextField("银行卡号", text: $viewModel.cardNumber)
|
||||
.keyboardType(.numberPad)
|
||||
Picker("开户银行", selection: $viewModel.bankName) {
|
||||
Text("请选择").tag("")
|
||||
ForEach(viewModel.banks, id: \.self) { bank in
|
||||
Text(bank).tag(bank)
|
||||
}
|
||||
}
|
||||
TextField("开户支行", text: $viewModel.branchName)
|
||||
Picker("省份", selection: $viewModel.provinceCode) {
|
||||
Text("请选择").tag("")
|
||||
ForEach(viewModel.areas) { area in
|
||||
Text(area.name).tag(area.code)
|
||||
}
|
||||
}
|
||||
Picker("城市", selection: $viewModel.cityCode) {
|
||||
Text("请选择").tag("")
|
||||
ForEach(selectedProvinceChildren) { city in
|
||||
Text(city.name).tag(city.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("银行卡照片") {
|
||||
bankCardImagePicker(title: "银行卡正面", item: $frontItem, data: viewModel.frontImageData, url: viewModel.frontImageURL)
|
||||
bankCardImagePicker(title: "银行卡反面", item: $backItem, data: viewModel.backImageData, url: viewModel.backImageURL)
|
||||
if viewModel.uploadProgress > 0 && viewModel.uploadProgress < 100 {
|
||||
ProgressView(value: Double(viewModel.uploadProgress), total: 100)
|
||||
}
|
||||
}
|
||||
|
||||
Section("短信验证码") {
|
||||
HStack {
|
||||
TextField("请输入短信验证码", text: $viewModel.smsCode)
|
||||
.keyboardType(.numberPad)
|
||||
Button(viewModel.smsCountdown > 0 ? "\(viewModel.smsCountdown)s" : "获取验证码") {
|
||||
Task { await viewModel.sendSms(api: walletAPI) }
|
||||
}
|
||||
.disabled(viewModel.smsCountdown > 0)
|
||||
}
|
||||
}
|
||||
|
||||
Button(viewModel.isSubmitting ? "提交中..." : "提交银行卡资料") {
|
||||
Task {
|
||||
if await viewModel.submit(api: walletAPI, uploader: ossUploadService, scenicId: accountContext.currentScenic?.id) {
|
||||
toastCenter.show("银行卡资料已提交")
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isSubmitting)
|
||||
}
|
||||
.navigationTitle("银行卡设置")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await viewModel.load(api: walletAPI) }
|
||||
.onChange(of: frontItem) { _, item in
|
||||
Task { viewModel.frontImageData = try? await item?.loadTransferable(type: Data.self) }
|
||||
}
|
||||
.onChange(of: backItem) { _, item in
|
||||
Task { viewModel.backImageData = try? await item?.loadTransferable(type: Data.self) }
|
||||
}
|
||||
.onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in
|
||||
if viewModel.smsCountdown > 0 {
|
||||
viewModel.smsCountdown -= 1
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.errorMessage) { _, message in
|
||||
if let message {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前所选省份下的城市列表。
|
||||
private var selectedProvinceChildren: [AreaNode] {
|
||||
viewModel.areas.first { $0.code == viewModel.provinceCode }?.children ?? []
|
||||
}
|
||||
|
||||
/// 银行卡图片选择行。
|
||||
private func bankCardImagePicker(title: String, item: Binding<PhotosPickerItem?>, data: Data?, url: String) -> some View {
|
||||
PhotosPicker(selection: item, matching: .images) {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
bankCardPreview(data: data, url: url)
|
||||
.frame(width: 88, height: 56)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
Text(title)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(AppDesign.placeholder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 银行卡图片预览。
|
||||
@ViewBuilder
|
||||
private func bankCardPreview(data: Data?, url: String) -> some View {
|
||||
if let data, let image = UIImage(data: data) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
RemoteImage(urlString: url) {
|
||||
Rectangle()
|
||||
.fill(AppDesign.primarySoft)
|
||||
.overlay {
|
||||
Image(systemName: "creditcard")
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 积分兑换页,展示积分概览、提交兑换申请和兑换记录。
|
||||
struct PointsRedemptionView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(WalletAPI.self) private var walletAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@State private var viewModel = PointsRedemptionViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
Text("可兑换积分")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Text("\(viewModel.overview.withdrawnPoints)")
|
||||
.font(.system(size: AppMetrics.FontSize.largeTitle, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
HStack {
|
||||
TextField("请输入兑换积分", text: $viewModel.pointsText)
|
||||
.keyboardType(.numberPad)
|
||||
Button("全部") {
|
||||
viewModel.fillAllPoints()
|
||||
}
|
||||
}
|
||||
.appInputFieldStyle(cornerRadius: 8, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||
Button(viewModel.isSubmitting ? "提交中..." : "提交兑换") {
|
||||
Task {
|
||||
if await viewModel.submit(api: walletAPI, staffId: staffId) {
|
||||
toastCenter.show("积分兑换申请已提交")
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: AppMetrics.ControlSize.primaryButtonHeight)
|
||||
.foregroundStyle(.white)
|
||||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
LazyVStack(spacing: AppMetrics.Spacing.small) {
|
||||
ForEach(viewModel.records) { item in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxxSmall) {
|
||||
Text("\(item.points) 积分")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
Text(item.createdAt)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Text("¥\(item.amount, specifier: "%.2f")")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
if viewModel.canLoadMore {
|
||||
Button("加载更多") {
|
||||
Task { await viewModel.loadMore(api: walletAPI) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||
.padding(.vertical, AppMetrics.Spacing.pageVertical)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("积分兑换")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await viewModel.load(api: walletAPI, staffId: staffId) }
|
||||
.onChange(of: viewModel.errorMessage) { _, message in
|
||||
if let message {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前用户 staffId。
|
||||
private var staffId: Int? {
|
||||
Int(accountContext.profile?.userId ?? "")
|
||||
}
|
||||
}
|
||||
31
suixinkan/Features/Wallet/Wallet.md
Normal file
31
suixinkan/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