Files
suixinkan_uikit/suixinkan/UI/Wallet/WalletViewController.swift
汉秋 05804ba7d6 完善钱包提现状态展示与举报摄影师列表交互。
对齐 Android 提现进度与时间线映射,优化钱包与积分兑换页刷新逻辑,并补充相关单元测试与真机测试说明。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 16:51:32 +08:00

608 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// WalletViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android
final class WalletViewController: BaseViewController, UITableViewDelegate {
private enum Section {
case main
}
private enum Item: Hashable {
case withdraw(WalletWithdrawItem)
case transactionHeader(EarningDetailGroup)
case transaction(EarningDetailItem)
case loading
}
private let viewModel: WalletViewModel
private let walletAPI: any WalletPageServing
private let profileAPI: any WalletProfileServing
private let summaryCard = WalletSummaryCardView()
private let contentCard = UIView()
private let tabStack = UIStackView()
private let withdrawTabButton = UIButton(type: .system)
private let transactionTabButton = UIButton(type: .system)
private let tabIndicator = UIView()
private let filterPanel = WalletFilterPanelView()
private let tableView = UITableView(frame: .zero, style: .plain)
private let refreshControl = UIRefreshControl()
private let bottomBar = UIView()
private let withdrawButton = AppButton(title: "提现到银行卡")
private var dataSource: UITableViewDiffableDataSource<Section, Item>!
private var lastShownMessage: String?
///
init(
viewModel: WalletViewModel = WalletViewModel(),
walletAPI: any WalletPageServing = NetworkServices.shared.walletAPI,
profileAPI: any WalletProfileServing = NetworkServices.shared.profileAPI
) {
self.viewModel = viewModel
self.walletAPI = walletAPI
self.profileAPI = profileAPI
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
let titleLabel = UILabel()
titleLabel.text = "我的钱包"
titleLabel.textColor = WalletTokens.text333
titleLabel.font = UIFont.systemFont(ofSize: 18)
navigationItem.titleView = titleLabel
}
override func setupUI() {
view.backgroundColor = WalletTokens.pageBackground
contentCard.backgroundColor = .white
contentCard.layer.cornerRadius = WalletTokens.contentRadius
contentCard.clipsToBounds = true
tabStack.axis = .horizontal
tabStack.distribution = .fillEqually
[withdrawTabButton, transactionTabButton].forEach {
$0.titleLabel?.font = WalletTokens.tabSelectedFont
$0.setTitleColor(WalletTokens.tabNormal, for: .normal)
tabStack.addArrangedSubview($0)
}
withdrawTabButton.setTitle("提现记录", for: .normal)
transactionTabButton.setTitle("收益明细", for: .normal)
tabIndicator.backgroundColor = WalletTokens.summaryBackground
tabIndicator.layer.cornerRadius = 1.5
tableView.backgroundColor = .white
tableView.separatorStyle = .none
tableView.delegate = self
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 120
tableView.refreshControl = refreshControl
tableView.contentInset = UIEdgeInsets(top: WalletTokens.rowGap, left: 0, bottom: WalletTokens.rowGap, right: 0)
tableView.verticalScrollIndicatorInsets = tableView.contentInset
tableView.register(WalletWithdrawRecordCell.self, forCellReuseIdentifier: WalletWithdrawRecordCell.reuseIdentifier)
tableView.register(WalletTransactionHeaderCell.self, forCellReuseIdentifier: WalletTransactionHeaderCell.reuseIdentifier)
tableView.register(WalletTransactionRecordCell.self, forCellReuseIdentifier: WalletTransactionRecordCell.reuseIdentifier)
tableView.register(WalletLoadingCell.self, forCellReuseIdentifier: WalletLoadingCell.reuseIdentifier)
bottomBar.backgroundColor = .white
view.addSubview(summaryCard)
view.addSubview(contentCard)
contentCard.addSubview(tabStack)
contentCard.addSubview(tabIndicator)
contentCard.addSubview(filterPanel)
contentCard.addSubview(tableView)
view.addSubview(bottomBar)
bottomBar.addSubview(withdrawButton)
configureDataSource()
}
override func setupConstraints() {
summaryCard.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(WalletTokens.screenInset)
make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
withdrawButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(WalletTokens.screenInset)
make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(22)
}
withdrawButton.layer.cornerRadius = WalletTokens.bottomButtonRadius
contentCard.snp.makeConstraints { make in
make.top.equalTo(summaryCard.snp.bottom).offset(WalletTokens.cardGap)
make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset)
make.bottom.equalTo(bottomBar.snp.top).offset(-WalletTokens.cardGap)
}
tabStack.snp.makeConstraints { make in
make.top.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(32)
make.height.equalTo(50)
}
tabIndicator.snp.makeConstraints { make in
make.width.equalTo(24)
make.height.equalTo(3)
make.bottom.equalTo(tabStack)
make.centerX.equalTo(withdrawTabButton)
}
filterPanel.snp.makeConstraints { make in
make.top.equalTo(tabStack.snp.bottom)
make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset)
make.height.equalTo(0)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(filterPanel.snp.bottom).offset(WalletTokens.rowGap)
make.leading.trailing.bottom.equalToSuperview()
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in
self?.applyState()
}
}
summaryCard.onPointsTap = { [weak self] in
self?.openPointsRedemption()
}
withdrawTabButton.addTarget(self, action: #selector(withdrawTabTapped), for: .touchUpInside)
transactionTabButton.addTarget(self, action: #selector(transactionTabTapped), for: .touchUpInside)
filterPanel.onFilterSelected = { [weak self] filter in
guard let self else { return }
Task { await self.viewModel.selectFilter(filter, api: self.walletAPI) }
}
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
withdrawButton.addTarget(self, action: #selector(withdrawTapped), for: .touchUpInside)
Task { await viewModel.refreshSummary(api: walletAPI) }
Task { await viewModel.refreshWithdraw(api: walletAPI) }
Task { await viewModel.refreshTransaction(api: walletAPI) }
Task { await viewModel.refreshPoints(api: walletAPI) }
}
private func configureDataSource() {
dataSource = UITableViewDiffableDataSource<Section, Item>(tableView: tableView) { tableView, indexPath, item in
switch item {
case .withdraw(let record):
let cell = tableView.dequeueReusableCell(
withIdentifier: WalletWithdrawRecordCell.reuseIdentifier,
for: indexPath
) as? WalletWithdrawRecordCell
cell?.apply(record)
return cell ?? UITableViewCell()
case .transactionHeader(let group):
let cell = tableView.dequeueReusableCell(
withIdentifier: WalletTransactionHeaderCell.reuseIdentifier,
for: indexPath
) as? WalletTransactionHeaderCell
cell?.apply(group)
return cell ?? UITableViewCell()
case .transaction(let record):
let cell = tableView.dequeueReusableCell(
withIdentifier: WalletTransactionRecordCell.reuseIdentifier,
for: indexPath
) as? WalletTransactionRecordCell
cell?.apply(record)
return cell ?? UITableViewCell()
case .loading:
return tableView.dequeueReusableCell(
withIdentifier: WalletLoadingCell.reuseIdentifier,
for: indexPath
)
}
}
}
private func applyState() {
summaryCard.apply(
withdrawable: viewModel.summary?.amountWithdrawable,
total: viewModel.summary?.amountTotal,
balance: viewModel.summary?.amountCurrentBalance,
points: viewModel.availablePoints
)
applyTabAppearance()
updateContentLayout()
filterPanel.apply(filter: viewModel.filter, totalIncome: viewModel.totalIncomeLabel, totalPoints: viewModel.totalPointsLabel)
let isRefreshing = switch viewModel.selectedTab {
case .withdraw: viewModel.withdrawRefreshing
case .transaction: viewModel.transactionRefreshing
}
if !isRefreshing {
refreshControl.endRefreshing()
}
applySnapshot()
if let message = viewModel.errorMessage, message != lastShownMessage {
lastShownMessage = message
showToast(message)
}
}
private func applyTabAppearance() {
let selectedColor = WalletTokens.summaryBackground
let normalColor = WalletTokens.tabNormal
withdrawTabButton.setTitleColor(viewModel.selectedTab == .withdraw ? selectedColor : normalColor, for: .normal)
transactionTabButton.setTitleColor(viewModel.selectedTab == .transaction ? selectedColor : normalColor, for: .normal)
withdrawTabButton.titleLabel?.font = viewModel.selectedTab == .withdraw ? WalletTokens.tabSelectedFont : WalletTokens.tabFont
transactionTabButton.titleLabel?.font = viewModel.selectedTab == .transaction ? WalletTokens.tabSelectedFont : WalletTokens.tabFont
tabIndicator.snp.remakeConstraints { make in
make.width.equalTo(24)
make.height.equalTo(3)
make.bottom.equalTo(tabStack)
make.centerX.equalTo(viewModel.selectedTab == .withdraw ? withdrawTabButton : transactionTabButton)
}
UIView.animate(withDuration: 0.2) {
self.contentCard.layoutIfNeeded()
}
}
private func updateContentLayout() {
let showsFilter = viewModel.selectedTab == .transaction
filterPanel.isHidden = !showsFilter
filterPanel.snp.remakeConstraints { make in
make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset)
if showsFilter {
make.top.equalTo(tabStack.snp.bottom).offset(WalletTokens.rowGap)
} else {
make.top.equalTo(tabStack.snp.bottom)
make.height.equalTo(0)
}
}
}
private func applySnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main])
if viewModel.selectedTab == .withdraw {
snapshot.appendItems(viewModel.withdrawRecords.map(Item.withdraw), toSection: .main)
if viewModel.withdrawLoading {
snapshot.appendItems([.loading], toSection: .main)
}
} else {
let items = viewModel.transactionEntries.map { entry -> Item in
switch entry {
case .header(let group): .transactionHeader(group)
case .item(let item): .transaction(item)
}
}
snapshot.appendItems(items, toSection: .main)
if viewModel.transactionLoading {
snapshot.appendItems([.loading], toSection: .main)
}
}
dataSource.apply(snapshot, animatingDifferences: true)
updateEmptyState()
}
private func updateEmptyState() {
let emptyText: String?
if viewModel.selectedTab == .withdraw, viewModel.withdrawRecords.isEmpty, !viewModel.withdrawLoading {
emptyText = "暂无提现记录"
} else if viewModel.selectedTab == .transaction, viewModel.transactionEntries.isEmpty, !viewModel.transactionLoading {
emptyText = "暂无收益明细"
} else {
emptyText = nil
}
guard let emptyText else {
tableView.backgroundView = nil
return
}
let label = UILabel()
label.text = emptyText
label.textColor = WalletTokens.textDisabled
label.font = WalletTokens.bodyFont
label.textAlignment = .center
tableView.backgroundView = label
}
private func openPointsRedemption() {
navigationController?.pushViewController(PointsRedemptionViewController(), animated: true)
}
@objc private func withdrawTabTapped() {
Task { await viewModel.selectTab(.withdraw, api: walletAPI) }
}
@objc private func transactionTabTapped() {
Task { await viewModel.selectTab(.transaction, api: walletAPI) }
}
@objc private func refreshPulled() {
Task { await viewModel.refreshSelectedTab(api: walletAPI) }
}
@objc private func withdrawTapped() {
Task {
showLoading()
do {
let destination = try await viewModel.resolveWithdrawDestination(profileAPI: profileAPI)
hideLoading()
route(to: destination)
} catch {
hideLoading()
showToast(error.localizedDescription)
}
}
}
private func route(to destination: WalletWithdrawDestination) {
switch destination {
case .realNameAuth:
navigationController?.pushViewController(RealNameAuthViewController(), animated: true)
case .realNameAudit(let info):
showToast("实名认证不通过,请重新提交")
navigationController?.pushViewController(RealNameAuthAuditViewController(info: info), animated: true)
case .realNamePending:
showToast("实名认证审核中,请审核通过后再试")
case .withdrawalSettings:
navigationController?.pushViewController(WithdrawalSettingsViewController(), animated: true)
case .withdrawalAudit(let bankCard):
showToast("银行卡审核不通过,请重新提交")
navigationController?.pushViewController(WithdrawalSettingsAuditViewController(bankCard: bankCard), animated: true)
case .bankCardPending:
showToast("银行卡审核中,请审核通过后再试")
case .withdraw:
let controller = WithdrawViewController()
controller.onSubmitSuccess = { [weak self] in
guard let self else { return }
Task {
await self.viewModel.refreshSummary(api: self.walletAPI)
await self.viewModel.refreshWithdraw(api: self.walletAPI)
}
}
navigationController?.pushViewController(controller, animated: true)
}
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
Task {
switch viewModel.selectedTab {
case .withdraw:
await viewModel.loadMoreWithdrawIfNeeded(currentIndex: indexPath.row, api: walletAPI)
case .transaction:
await viewModel.loadMoreTransactionIfNeeded(currentIndex: indexPath.row, api: walletAPI)
}
}
}
}
///
private final class WalletSummaryCardView: UIView {
var onPointsTap: (() -> Void)?
private let withdrawableTitle = UILabel()
private let withdrawableLabel = UILabel()
private let totalView = WalletSummaryMetricView()
private let balanceView = WalletSummaryMetricView()
private let pointsView = WalletSummaryMetricView(showsArrow: true)
private let metricsStack = UIStackView()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(withdrawable: String?, total: String?, balance: String?, points: Int) {
withdrawableLabel.text = WalletViewModel.formatAmount(withdrawable)
totalView.apply(title: "累计金额", value: WalletViewModel.formatAmount(total))
balanceView.apply(title: "当前余额", value: WalletViewModel.formatAmount(balance))
pointsView.apply(title: "当前积分", value: "\(points)")
}
private func setupUI() {
backgroundColor = WalletTokens.summaryBackground
layer.cornerRadius = WalletTokens.summaryRadius
clipsToBounds = true
withdrawableTitle.text = "可提现金额"
withdrawableTitle.textColor = UIColor.white.withAlphaComponent(0.8)
withdrawableTitle.font = WalletTokens.bodyFont
withdrawableTitle.textAlignment = .center
withdrawableLabel.textColor = .white
withdrawableLabel.font = WalletTokens.displayFont
withdrawableLabel.textAlignment = .center
withdrawableLabel.adjustsFontSizeToFitWidth = true
withdrawableLabel.minimumScaleFactor = 0.7
metricsStack.axis = .horizontal
metricsStack.distribution = .fillEqually
[totalView, balanceView, pointsView].forEach(metricsStack.addArrangedSubview)
pointsView.addTarget(self, action: #selector(pointsTapped), for: .touchUpInside)
addSubview(withdrawableTitle)
addSubview(withdrawableLabel)
addSubview(metricsStack)
withdrawableTitle.snp.makeConstraints { make in
make.top.equalToSuperview().offset(WalletTokens.cardPadding)
make.leading.trailing.equalToSuperview().inset(WalletTokens.summaryHorizontalPadding)
}
withdrawableLabel.snp.makeConstraints { make in
make.top.equalTo(withdrawableTitle.snp.bottom).offset(WalletTokens.tinyGap)
make.leading.trailing.equalToSuperview().inset(WalletTokens.summaryHorizontalPadding)
}
metricsStack.snp.makeConstraints { make in
make.top.equalTo(withdrawableLabel.snp.bottom).offset(18)
make.leading.trailing.equalToSuperview().inset(WalletTokens.summaryHorizontalPadding)
make.bottom.equalToSuperview().inset(WalletTokens.cardPadding)
}
}
@objc private func pointsTapped() {
onPointsTap?()
}
}
///
private final class WalletSummaryMetricView: UIControl {
private let titleLabel = UILabel()
private let valueLabel = UILabel()
private let arrowLabel = UILabel()
private let showsArrow: Bool
init(showsArrow: Bool = false) {
self.showsArrow = showsArrow
super.init(frame: .zero)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(title: String, value: String) {
titleLabel.text = title
valueLabel.text = value
}
private func setupUI() {
titleLabel.textColor = UIColor.white.withAlphaComponent(0.8)
titleLabel.font = WalletTokens.bodyFont
titleLabel.textAlignment = .center
valueLabel.textColor = .white
valueLabel.font = WalletTokens.bodyMediumFont
valueLabel.textAlignment = .center
valueLabel.adjustsFontSizeToFitWidth = true
valueLabel.minimumScaleFactor = 0.75
arrowLabel.text = showsArrow ? "" : ""
arrowLabel.textColor = .white
arrowLabel.font = .systemFont(ofSize: 20, weight: .medium)
let valueStack = UIStackView(arrangedSubviews: [valueLabel, arrowLabel])
valueStack.axis = .horizontal
valueStack.alignment = .center
valueStack.spacing = 2
valueStack.distribution = .fill
addSubview(titleLabel)
addSubview(valueStack)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
valueStack.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(6)
make.centerX.equalToSuperview()
make.leading.greaterThanOrEqualToSuperview()
make.trailing.lessThanOrEqualToSuperview()
make.bottom.equalToSuperview()
}
}
}
///
private final class WalletFilterPanelView: UIView {
var onFilterSelected: ((WalletFilter) -> Void)?
private let filterButton = UIButton(type: .system)
private let totalTitleLabel = UILabel()
private let totalLabel = UILabel()
private let pointsLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(filter: WalletFilter, totalIncome: String, totalPoints: Int) {
filterButton.configuration?.title = filter.title
configureMenu(selected: filter)
totalLabel.text = totalIncome.isEmpty ? "¥ 0.00" : totalIncome
pointsLabel.text = "积分+\(totalPoints)"
}
private func setupUI() {
backgroundColor = WalletTokens.filterBackground
layer.cornerRadius = WalletTokens.contentRadius
clipsToBounds = true
filterButton.setTitleColor(UIColor(hex: 0x111827), for: .normal)
filterButton.titleLabel?.font = WalletTokens.bodyFont
filterButton.contentHorizontalAlignment = .left
filterButton.backgroundColor = WalletTokens.inputFocusedBackground
filterButton.layer.cornerRadius = WalletTokens.contentRadius
filterButton.showsMenuAsPrimaryAction = true
filterButton.changesSelectionAsPrimaryAction = false
filterButton.configuration = .plain()
filterButton.configuration?.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 12)
filterButton.configuration?.image = UIImage(systemName: "chevron.down")
filterButton.configuration?.imagePlacement = .trailing
filterButton.configuration?.imagePadding = 8
filterButton.tintColor = WalletTokens.text333
totalTitleLabel.text = "总收益:"
totalTitleLabel.font = WalletTokens.bodyMediumFont
totalTitleLabel.textColor = .black
totalLabel.font = WalletTokens.bodyMediumFont
totalLabel.textColor = WalletTokens.primary
pointsLabel.font = WalletTokens.bodyMediumFont
pointsLabel.textColor = WalletTokens.points
pointsLabel.textAlignment = .right
addSubview(filterButton)
addSubview(totalTitleLabel)
addSubview(totalLabel)
addSubview(pointsLabel)
filterButton.snp.makeConstraints { make in
make.top.leading.equalToSuperview()
make.width.lessThanOrEqualTo(WalletTokens.filterFieldMaxWidth)
make.height.equalTo(WalletTokens.filterFieldHeight)
}
totalTitleLabel.snp.makeConstraints { make in
make.top.equalTo(filterButton.snp.bottom)
make.leading.equalToSuperview().offset(WalletTokens.cardPadding)
make.bottom.equalToSuperview().inset(WalletTokens.rowGap)
}
totalLabel.snp.makeConstraints { make in
make.centerY.equalTo(totalTitleLabel)
make.leading.equalTo(totalTitleLabel.snp.trailing).offset(8)
}
pointsLabel.snp.makeConstraints { make in
make.centerY.equalTo(totalTitleLabel)
make.leading.greaterThanOrEqualTo(totalLabel.snp.trailing).offset(8)
make.trailing.equalToSuperview().inset(16)
}
}
private func configureMenu(selected: WalletFilter) {
let actions = WalletFilter.allCases.map { [weak self] filter in
UIAction(
title: filter.title,
state: filter == selected ? .on : .off
) { _ in
self?.onFilterSelected?(filter)
}
}
filterButton.menu = UIMenu(children: actions)
}
}