Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View 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 {}

View 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
}
}

View File

@ -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 {}

View 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)
}
}

View 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 和积分兑换表单均不落盘。