feat: update wallet punch point and report success

This commit is contained in:
2026-07-09 14:27:18 +08:00
parent 2970f1514b
commit 43e6133c21
34 changed files with 6671 additions and 71 deletions

View File

@ -0,0 +1,468 @@
//
// PointsRedemptionViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android `PointsRedemptionScreen`
final class PointsRedemptionViewController: BaseViewController, UITableViewDelegate {
private enum Section {
case main
}
private let viewModel: PointsRedemptionViewModel
private let walletAPI: any WalletPageServing
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let pointsCard = UIView()
private let pointsValueLabel = UILabel()
private let inputCard = UIView()
private let pointsInputField = UITextField()
private let withdrawablePointsLabel = UILabel()
private let recordCard = UIView()
private let tableView = UITableView(frame: .zero, style: .plain)
private let refreshControl = UIRefreshControl()
private let bottomBar = UIView()
private let submitButton = AppButton(title: "立即兑换")
private var dataSource: UITableViewDiffableDataSource<Section, PointWithdrawItem>!
private var lastShownMessage: String?
///
init(
viewModel: PointsRedemptionViewModel = PointsRedemptionViewModel(),
walletAPI: any WalletPageServing = NetworkServices.shared.walletAPI
) {
self.viewModel = viewModel
self.walletAPI = walletAPI
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "积分兑现"
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF5F6F8)
contentStack.axis = .vertical
contentStack.spacing = 16
bottomBar.backgroundColor = .white
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
view.addSubview(bottomBar)
bottomBar.addSubview(submitButton)
contentStack.addArrangedSubview(makeSummaryCard())
contentStack.addArrangedSubview(makeInputCard())
contentStack.addArrangedSubview(makeRecordCard())
configureTable()
}
override func setupConstraints() {
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
submitButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(22)
}
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
make.width.equalTo(scrollView).offset(-32)
}
tableView.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(260)
make.height.lessThanOrEqualTo(420)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyState() }
}
pointsInputField.addTarget(self, action: #selector(pointsInputChanged), for: .editingChanged)
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
Task { await viewModel.loadInitial(api: walletAPI) }
}
private func configureTable() {
tableView.backgroundColor = .white
tableView.separatorStyle = .none
tableView.delegate = self
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 120
tableView.refreshControl = refreshControl
tableView.register(PointWithdrawRecordCell.self, forCellReuseIdentifier: PointWithdrawRecordCell.reuseIdentifier)
dataSource = UITableViewDiffableDataSource<Section, PointWithdrawItem>(tableView: tableView) { tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(
withIdentifier: PointWithdrawRecordCell.reuseIdentifier,
for: indexPath
) as? PointWithdrawRecordCell
cell?.apply(item)
return cell ?? UITableViewCell()
}
}
private func applyState() {
pointsValueLabel.text = "\(viewModel.withdrawnPoints)"
withdrawablePointsLabel.text = "\(viewModel.withdrawnPoints)"
pointsInputField.text = viewModel.pointsInput
submitButton.isEnabled = (Int(viewModel.pointsInput) ?? 0) > 0
refreshControl.endRefreshing()
var snapshot = NSDiffableDataSourceSnapshot<Section, PointWithdrawItem>()
snapshot.appendSections([.main])
snapshot.appendItems(viewModel.withdrawRecords, toSection: .main)
dataSource.apply(snapshot, animatingDifferences: true)
updateEmptyState()
showMessageIfNeeded(viewModel.statusMessage)
showMessageIfNeeded(viewModel.errorMessage)
}
private func updateEmptyState() {
guard viewModel.withdrawRecords.isEmpty, !viewModel.withdrawLoading else {
tableView.backgroundView = nil
return
}
let label = UILabel()
label.text = "暂无提现记录"
label.font = .systemFont(ofSize: 14)
label.textColor = UIColor(hex: 0xB3B8C2)
label.textAlignment = .center
tableView.backgroundView = label
}
private func makeSummaryCard() -> UIView {
pointsCard.backgroundColor = UIColor(hex: 0x1677FF)
pointsCard.layer.cornerRadius = 16
pointsCard.clipsToBounds = true
let title = UILabel()
title.text = "可提现积分"
title.font = .systemFont(ofSize: 14)
title.textColor = UIColor.white.withAlphaComponent(0.8)
title.textAlignment = .center
pointsValueLabel.font = .systemFont(ofSize: 36, weight: .bold)
pointsValueLabel.textColor = .white
pointsValueLabel.textAlignment = .center
pointsCard.addSubview(title)
pointsCard.addSubview(pointsValueLabel)
title.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(24)
}
pointsValueLabel.snp.makeConstraints { make in
make.top.equalTo(title.snp.bottom).offset(4)
make.leading.trailing.equalToSuperview().inset(24)
make.bottom.equalToSuperview().inset(16)
}
return pointsCard
}
private func makeInputCard() -> UIView {
inputCard.backgroundColor = .white
inputCard.layer.cornerRadius = 12
inputCard.clipsToBounds = true
let title = makeTitle("积分提现", size: 18)
let row = UIStackView()
row.axis = .horizontal
row.spacing = 16
let label = makeBody("可提现积分", color: .black, weight: .medium)
withdrawablePointsLabel.font = .systemFont(ofSize: 14, weight: .medium)
withdrawablePointsLabel.textColor = UIColor(hex: 0xFF7B00)
row.addArrangedSubview(label)
row.addArrangedSubview(withdrawablePointsLabel)
let inputContainer = UIView()
inputContainer.layer.cornerRadius = 4
inputContainer.layer.borderWidth = 1
inputContainer.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
pointsInputField.placeholder = "请输入积分"
pointsInputField.keyboardType = .numberPad
pointsInputField.font = .systemFont(ofSize: 14)
let allButton = UIButton(type: .system)
allButton.setTitle("全部提现", for: .normal)
allButton.setTitleColor(UIColor(hex: 0x0073FF), for: .normal)
allButton.titleLabel?.font = .systemFont(ofSize: 14)
allButton.addTarget(self, action: #selector(withdrawAllTapped), for: .touchUpInside)
inputContainer.addSubview(pointsInputField)
inputContainer.addSubview(allButton)
pointsInputField.snp.makeConstraints { make in
make.top.bottom.equalToSuperview()
make.leading.equalToSuperview().offset(12)
make.trailing.equalTo(allButton.snp.leading).offset(-8)
}
allButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(12)
make.centerY.equalToSuperview()
}
inputContainer.snp.makeConstraints { make in make.height.equalTo(46) }
let ratio = makeBody("积分金额兑换比例12积分=1元人民币", color: UIColor(hex: 0xEF4444))
ratio.font = .systemFont(ofSize: 12)
let stack = UIStackView(arrangedSubviews: [title, row, inputContainer, ratio])
stack.axis = .vertical
stack.spacing = 12
inputCard.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
return inputCard
}
private func makeRecordCard() -> UIView {
recordCard.backgroundColor = .white
recordCard.layer.cornerRadius = 12
recordCard.clipsToBounds = true
let title = makeTitle("积分提现记录", size: 14)
title.textColor = UIColor(hex: 0x0073FF)
title.textAlignment = .center
recordCard.addSubview(title)
recordCard.addSubview(tableView)
title.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(16)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(title.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview().inset(16)
}
return recordCard
}
private func makeTitle(_ text: String, size: CGFloat) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: size, weight: .medium)
label.textColor = .black
return label
}
private func makeBody(_ text: String, color: UIColor, weight: UIFont.Weight = .regular) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 14, weight: weight)
label.textColor = color
return label
}
private func showMessageIfNeeded(_ message: String?) {
guard let message, !message.isEmpty, message != lastShownMessage else { return }
lastShownMessage = message
showToast(message)
}
@objc private func pointsInputChanged() {
viewModel.updatePointsInput(pointsInputField.text ?? "")
}
@objc private func withdrawAllTapped() {
viewModel.withdrawAll()
}
@objc private func refreshPulled() {
Task { await viewModel.loadInitial(api: walletAPI) }
}
@objc private func submitTapped() {
Task {
showLoading()
await viewModel.applyWithdraw(api: walletAPI)
hideLoading()
}
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
Task { await viewModel.loadMoreWithdrawListIfNeeded(currentIndex: indexPath.row, api: walletAPI) }
}
}
/// Cell
private final class PointWithdrawRecordCell: UITableViewCell {
static let reuseIdentifier = "PointWithdrawRecordCell"
private let cardView = UIView()
private let pointsLabel = UILabel()
private let amountLabel = UILabel()
private let timeLabel = UILabel()
private let progressView = UIProgressView(progressViewStyle: .bar)
private let stepsStack = UIStackView()
private let infoLabel = UILabel()
private let statusLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func apply(_ record: PointWithdrawItem) {
pointsLabel.text = "-\(record.points)积分"
amountLabel.text = record.amount != nil && record.status == 2 ? "+\(formatAmount(record.amount))" : ""
amountLabel.isHidden = amountLabel.text?.isEmpty ?? true
timeLabel.text = record.createdAt.isEmpty ? "----" : record.createdAt
let style = statusStyle(for: record.status)
statusLabel.text = statusText(for: record.status)
statusLabel.textColor = style.text
statusLabel.backgroundColor = style.background
let progress = progress(for: record.status)
progressView.progress = Float(progress) / 100.0
progressView.progressTintColor = style.progress
progressView.isHidden = progress == 0
stepsStack.isHidden = progress == 0
configureSteps(progress: progress, tint: style.progress)
infoLabel.text = infoText(for: record)
}
private func setupUI() {
selectionStyle = .none
backgroundColor = .white
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 16
cardView.layer.borderWidth = 1
cardView.layer.borderColor = UIColor(hex: 0xF3F4F6).cgColor
cardView.clipsToBounds = true
pointsLabel.font = .systemFont(ofSize: 18, weight: .medium)
pointsLabel.textColor = .black
amountLabel.font = .systemFont(ofSize: 18, weight: .medium)
amountLabel.textColor = UIColor(hex: 0xEF4444)
amountLabel.textAlignment = .right
timeLabel.font = .systemFont(ofSize: 14)
timeLabel.textColor = UIColor(hex: 0x7B8EAA)
progressView.trackTintColor = UIColor(hex: 0xF2F4F8)
progressView.layer.cornerRadius = 3
progressView.clipsToBounds = true
stepsStack.axis = .horizontal
stepsStack.distribution = .equalSpacing
statusLabel.font = .systemFont(ofSize: 12)
statusLabel.textAlignment = .center
statusLabel.layer.cornerRadius = 4
statusLabel.clipsToBounds = true
infoLabel.font = .systemFont(ofSize: 14)
infoLabel.textColor = UIColor(hex: 0x4B5563)
infoLabel.numberOfLines = 0
contentView.addSubview(cardView)
[pointsLabel, amountLabel, timeLabel, progressView, stepsStack, infoLabel, statusLabel].forEach(cardView.addSubview)
cardView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 0, bottom: 6, right: 0))
}
pointsLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(16)
}
amountLabel.snp.makeConstraints { make in
make.centerY.equalTo(pointsLabel)
make.leading.greaterThanOrEqualTo(pointsLabel.snp.trailing).offset(12)
make.trailing.equalToSuperview().inset(16)
}
timeLabel.snp.makeConstraints { make in
make.top.equalTo(pointsLabel.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(16)
}
progressView.snp.makeConstraints { make in
make.top.equalTo(timeLabel.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(6)
}
stepsStack.snp.makeConstraints { make in
make.top.equalTo(progressView.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(16)
}
infoLabel.snp.makeConstraints { make in
make.top.equalTo(stepsStack.snp.bottom).offset(10)
make.leading.equalToSuperview().offset(16)
make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-8)
make.bottom.equalToSuperview().inset(14)
}
statusLabel.snp.makeConstraints { make in
make.centerY.equalTo(infoLabel)
make.trailing.equalToSuperview().inset(16)
make.height.equalTo(22)
make.width.greaterThanOrEqualTo(70)
}
}
private func configureSteps(progress: Int, tint: UIColor) {
stepsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
["提交申请", "审核中", "打款中", "已完成"].enumerated().forEach { index, title in
let label = UILabel()
label.text = title
label.font = .systemFont(ofSize: 11)
label.textColor = progress >= Int((Float(index) / 3.0) * 100) ? tint : UIColor(hex: 0x7B8EAA)
stepsStack.addArrangedSubview(label)
}
let percent = UILabel()
percent.text = "\(progress)%"
percent.font = .systemFont(ofSize: 12, weight: .medium)
percent.textColor = UIColor(hex: 0x9CA3AF)
stepsStack.addArrangedSubview(percent)
}
private func statusText(for status: Int) -> String {
switch status {
case 2: "提现成功"
case 1: "提现中"
case 3: "提现失败"
default: "未知"
}
}
private func statusStyle(for status: Int) -> (background: UIColor, text: UIColor, progress: UIColor) {
switch status {
case 2:
(UIColor(hex: 0xF0FDF4), UIColor(hex: 0x22C55E), UIColor(hex: 0x1677FF))
case 1:
(UIColor(hex: 0xFFF0E2), UIColor(hex: 0xFF7B00), UIColor(hex: 0x1677FF))
case 3:
(UIColor(hex: 0xFFE7E7), UIColor(hex: 0xEF4444), UIColor(hex: 0xFF4D4F))
default:
(UIColor(hex: 0xE6F1FF), UIColor(hex: 0x1677FF), UIColor(hex: 0x1677FF))
}
}
private func progress(for status: Int) -> Int {
switch status {
case 1: 40
case 3: 100
default: 0
}
}
private func infoText(for record: PointWithdrawItem) -> String {
switch record.status {
case 3:
let remark = record.rejectReason.isEmpty ? "" : "\n备注信息:\(record.rejectReason)"
return "审核时间:\(record.reviewedAt.isEmpty ? "暂无" : record.reviewedAt)\(remark)"
case 2:
return "到账时间:\(record.paymentTime.isEmpty ? "暂无" : record.paymentTime)"
default:
return "预计到账时间:\(record.estimatedReceiptTime.isEmpty ? "暂无" : record.estimatedReceiptTime)"
}
}
private func formatAmount(_ value: Double?) -> String {
guard let value else { return "¥0.00" }
return String(format: "¥%.2f", value)
}
}

View File

@ -0,0 +1,348 @@
//
// WalletCells.swift
// suixinkan
//
import SnapKit
import UIKit
/// Cell
final class WalletWithdrawRecordCell: UITableViewCell {
static let reuseIdentifier = "WalletWithdrawRecordCell"
private let cardView = UIView()
private let amountLabel = UILabel()
private let statusLabel = UILabel()
private let timeLabel = UILabel()
private let progressView = UIProgressView(progressViewStyle: .bar)
private let stepsStack = UIStackView()
private let infoLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func apply(_ record: WalletWithdrawItem) {
amountLabel.text = WalletViewModel.formatAmount(record.amount)
statusLabel.text = record.statusLabel.isEmpty ? "--" : record.statusLabel
timeLabel.text = record.createdAt.isEmpty ? "--" : record.createdAt
let style = statusStyle(for: record.settlementStatus)
statusLabel.textColor = style.text
statusLabel.backgroundColor = style.background
progressView.progressTintColor = style.progress
progressView.progress = progress(for: record.settlementStatus)
configureSteps(progress: progress(for: record.settlementStatus), tint: style.progress)
progressView.isHidden = record.settlementStatus == 50
stepsStack.isHidden = record.settlementStatus == 50
infoLabel.text = infoText(for: record)
infoLabel.isHidden = infoLabel.text?.isEmpty ?? true
}
private func setupUI() {
selectionStyle = .none
backgroundColor = .white
contentView.backgroundColor = .white
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 16
cardView.layer.borderWidth = 1
cardView.layer.borderColor = UIColor(hex: 0xF3F4F6).cgColor
cardView.clipsToBounds = true
amountLabel.font = .systemFont(ofSize: 18, weight: .bold)
amountLabel.textColor = .black
statusLabel.font = .systemFont(ofSize: 12, weight: .medium)
statusLabel.textAlignment = .center
statusLabel.layer.cornerRadius = 4
statusLabel.clipsToBounds = true
timeLabel.font = .systemFont(ofSize: 14)
timeLabel.textColor = UIColor(hex: 0x7B8EAA)
progressView.trackTintColor = UIColor(hex: 0xF2F4F8)
progressView.layer.cornerRadius = 3
progressView.clipsToBounds = true
stepsStack.axis = .horizontal
stepsStack.distribution = .equalSpacing
stepsStack.alignment = .center
infoLabel.font = .systemFont(ofSize: 14)
infoLabel.textColor = UIColor(hex: 0x4B5563)
infoLabel.numberOfLines = 0
contentView.addSubview(cardView)
[amountLabel, statusLabel, timeLabel, progressView, stepsStack, infoLabel].forEach(cardView.addSubview)
cardView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 16, bottom: 6, right: 16))
}
amountLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(16)
make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-12)
}
statusLabel.snp.makeConstraints { make in
make.centerY.equalTo(amountLabel)
make.trailing.equalToSuperview().inset(16)
make.height.equalTo(24)
make.width.greaterThanOrEqualTo(62)
}
timeLabel.snp.makeConstraints { make in
make.top.equalTo(amountLabel.snp.bottom).offset(6)
make.leading.trailing.equalToSuperview().inset(16)
}
progressView.snp.makeConstraints { make in
make.top.equalTo(timeLabel.snp.bottom).offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(6)
}
stepsStack.snp.makeConstraints { make in
make.top.equalTo(progressView.snp.bottom).offset(10)
make.leading.trailing.equalToSuperview().inset(16)
}
infoLabel.snp.makeConstraints { make in
make.top.equalTo(stepsStack.snp.bottom).offset(10)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalToSuperview().inset(14)
}
}
private func configureSteps(progress: Float, tint: UIColor) {
stepsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
["提交申请", "审核中", "打款中", "已完成"].enumerated().forEach { index, title in
let label = UILabel()
label.text = title
label.font = .systemFont(ofSize: 11)
let threshold = Float(index) / 3.0
label.textColor = progress >= threshold ? tint : UIColor(hex: 0xB3B8C2)
stepsStack.addArrangedSubview(label)
}
let percent = UILabel()
percent.text = "\(Int(progress * 100))%"
percent.font = .systemFont(ofSize: 12, weight: .medium)
percent.textColor = tint
stepsStack.addArrangedSubview(percent)
}
private func statusStyle(for status: Int) -> (background: UIColor, text: UIColor, progress: UIColor) {
switch status {
case 50, 20:
(UIColor(hex: 0xEBFFF5), UIColor(hex: 0x16C26D), UIColor(hex: 0x1677FF))
case 60, 30:
(UIColor(hex: 0xFFF1F0), UIColor(hex: 0xFF4D4F), UIColor(hex: 0xFF4D4F))
case 40:
(UIColor(hex: 0xFFF3E8), UIColor(hex: 0xFF8A00), UIColor(hex: 0x1677FF))
default:
(UIColor(hex: 0xE6F1FF), UIColor(hex: 0x1677FF), UIColor(hex: 0x1677FF))
}
}
private func progress(for status: Int) -> Float {
switch status {
case 10: 0.1
case 40: 0.4
case 20: 0.8
case 30: 1.0
case 60: 0.0
default: 1.0
}
}
private func infoText(for record: WalletWithdrawItem) -> String {
if (record.settlementStatus == 10 || record.settlementStatus == 40), !record.expectedAt.isEmpty {
return "预计到账时间:\(record.expectedAt)"
}
if (record.settlementStatus == 60 || record.settlementStatus == 30), !record.auditTime.isEmpty {
let remark = record.auditRemark.isEmpty ? "" : "\n备注信息:\(record.auditRemark)"
return "审核时间:\(record.auditTime)\(remark)"
}
if record.settlementStatus == 50, !record.expectedAt.isEmpty {
return "到账时间:\(record.expectedAt)"
}
return ""
}
}
/// Cell
final class WalletTransactionHeaderCell: UITableViewCell {
static let reuseIdentifier = "WalletTransactionHeaderCell"
private let dateLabel = UILabel()
private let incomeLabel = UILabel()
private let pointsLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func apply(_ group: EarningDetailGroup) {
dateLabel.text = group.date
incomeLabel.text = "当日收益:\(WalletViewModel.formatAmount(group.dayAmount))"
pointsLabel.text = group.dayPoints > 0 ? "积分+\(group.dayPoints)" : ""
}
private func setupUI() {
selectionStyle = .none
backgroundColor = .white
dateLabel.font = .systemFont(ofSize: 14)
dateLabel.textColor = UIColor(hex: 0x9CA3AF)
incomeLabel.font = .systemFont(ofSize: 14)
incomeLabel.textColor = UIColor(hex: 0x4B5563)
incomeLabel.textAlignment = .right
pointsLabel.font = .systemFont(ofSize: 14)
pointsLabel.textColor = UIColor(hex: 0xFF7B00)
pointsLabel.textAlignment = .right
contentView.addSubview(dateLabel)
contentView.addSubview(incomeLabel)
contentView.addSubview(pointsLabel)
dateLabel.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(8)
make.leading.equalToSuperview().offset(16)
}
pointsLabel.snp.makeConstraints { make in
make.centerY.equalTo(dateLabel)
make.trailing.equalToSuperview().inset(16)
}
incomeLabel.snp.makeConstraints { make in
make.centerY.equalTo(dateLabel)
make.leading.greaterThanOrEqualTo(dateLabel.snp.trailing).offset(8)
make.trailing.equalTo(pointsLabel.snp.leading).offset(-12)
}
}
}
/// Cell
final class WalletTransactionRecordCell: UITableViewCell {
static let reuseIdentifier = "WalletTransactionRecordCell"
private let cardView = UIView()
private let amountLabel = UILabel()
private let pointsLabel = UILabel()
private let typeLabel = UILabel()
private let orderLabel = UILabel()
private let timeLabel = UILabel()
private let withdrawLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func apply(_ item: EarningDetailItem) {
amountLabel.text = item.amount.isEmpty ? "" : WalletViewModel.formatAmount(item.amount)
pointsLabel.text = item.points > 0 ? "积分+\(item.points)" : ""
amountLabel.isHidden = item.amount.isEmpty
pointsLabel.isHidden = item.points <= 0
if item.amount.isEmpty, item.points <= 0 {
amountLabel.text = "--"
amountLabel.isHidden = false
}
typeLabel.text = item.typeLabel
orderLabel.text = item.orderNumberSuffix
orderLabel.isHidden = item.orderNumberSuffix.isEmpty
timeLabel.text = item.createdAt
withdrawLabel.text = item.withdrawLabel
withdrawLabel.isHidden = item.withdrawLabel.isEmpty
let style = labelStyle(for: item.withdrawLabel)
withdrawLabel.textColor = style.text
withdrawLabel.backgroundColor = style.background
orderLabel.textColor = style.orderText
orderLabel.backgroundColor = style.orderBackground
}
private func setupUI() {
selectionStyle = .none
backgroundColor = .white
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 12
cardView.layer.borderWidth = 1
cardView.layer.borderColor = UIColor(hex: 0xF3F4F6).cgColor
cardView.clipsToBounds = true
amountLabel.font = .systemFont(ofSize: 18, weight: .medium)
amountLabel.textColor = .black
pointsLabel.font = .systemFont(ofSize: 18, weight: .medium)
pointsLabel.textColor = UIColor(hex: 0xFF7B00)
pointsLabel.textAlignment = .right
typeLabel.font = .systemFont(ofSize: 14)
typeLabel.textColor = UIColor(hex: 0x9CA3AF)
orderLabel.font = .systemFont(ofSize: 12)
orderLabel.textAlignment = .center
orderLabel.layer.cornerRadius = 4
orderLabel.clipsToBounds = true
timeLabel.font = .systemFont(ofSize: 14)
timeLabel.textColor = UIColor(hex: 0x4B5563)
withdrawLabel.font = .systemFont(ofSize: 12)
withdrawLabel.textAlignment = .center
withdrawLabel.layer.cornerRadius = 4
withdrawLabel.clipsToBounds = true
contentView.addSubview(cardView)
[amountLabel, pointsLabel, typeLabel, orderLabel, timeLabel, withdrawLabel].forEach(cardView.addSubview)
cardView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 16, bottom: 6, right: 16))
}
amountLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(16)
}
pointsLabel.snp.makeConstraints { make in
make.centerY.equalTo(amountLabel)
make.leading.greaterThanOrEqualTo(amountLabel.snp.trailing).offset(12)
make.trailing.equalToSuperview().inset(16)
}
typeLabel.snp.makeConstraints { make in
make.top.equalTo(amountLabel.snp.bottom).offset(10)
make.leading.equalToSuperview().offset(16)
}
orderLabel.snp.makeConstraints { make in
make.centerY.equalTo(typeLabel)
make.leading.equalTo(typeLabel.snp.trailing).offset(8)
make.height.equalTo(20)
make.width.greaterThanOrEqualTo(36)
}
timeLabel.snp.makeConstraints { make in
make.top.equalTo(typeLabel.snp.bottom).offset(12)
make.leading.equalToSuperview().offset(16)
make.bottom.equalToSuperview().inset(16)
}
withdrawLabel.snp.makeConstraints { make in
make.centerY.equalTo(timeLabel)
make.trailing.equalToSuperview().inset(16)
make.height.equalTo(20)
make.width.greaterThanOrEqualTo(48)
}
}
private func labelStyle(for label: String) -> (background: UIColor, text: UIColor, orderBackground: UIColor, orderText: UIColor) {
switch label {
case "可提现":
return (UIColor(hex: 0xF0FDF4), UIColor(hex: 0x22C55E), UIColor(hex: 0xEFF6FF), UIColor(hex: 0x0073FF))
default:
return (UIColor(hex: 0xF4F4F4), UIColor(hex: 0x7B8EAA), UIColor(hex: 0xF4F4F4), UIColor(hex: 0x7B8EAA))
}
}
}

View File

@ -0,0 +1,546 @@
//
// 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)
}
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 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() {
title = "我的钱包"
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF5F6F8)
contentCard.backgroundColor = .white
contentCard.layer.cornerRadius = 16
contentCard.clipsToBounds = true
tabStack.axis = .horizontal
tabStack.distribution = .fillEqually
[withdrawTabButton, transactionTabButton].forEach {
$0.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
$0.setTitleColor(UIColor(hex: 0x666666), for: .normal)
tabStack.addArrangedSubview($0)
}
withdrawTabButton.setTitle("提现记录", for: .normal)
transactionTabButton.setTitle("收益明细", for: .normal)
tableView.backgroundColor = .white
tableView.separatorStyle = .none
tableView.delegate = self
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 120
tableView.refreshControl = refreshControl
tableView.register(WalletWithdrawRecordCell.self, forCellReuseIdentifier: WalletWithdrawRecordCell.reuseIdentifier)
tableView.register(WalletTransactionHeaderCell.self, forCellReuseIdentifier: WalletTransactionHeaderCell.reuseIdentifier)
tableView.register(WalletTransactionRecordCell.self, forCellReuseIdentifier: WalletTransactionRecordCell.reuseIdentifier)
bottomBar.backgroundColor = .white
view.addSubview(summaryCard)
view.addSubview(contentCard)
contentCard.addSubview(tabStack)
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(16)
make.leading.trailing.equalToSuperview().inset(16)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
withdrawButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(22)
}
contentCard.snp.makeConstraints { make in
make.top.equalTo(summaryCard.snp.bottom).offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(bottomBar.snp.top).offset(-16)
}
tabStack.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(50)
}
filterPanel.snp.makeConstraints { make in
make.top.equalTo(tabStack.snp.bottom)
make.leading.trailing.equalToSuperview().inset(16)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(filterPanel.snp.bottom).offset(4)
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.onFilterTap = { [weak self] in
self?.presentFilterMenu()
}
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
withdrawButton.addTarget(self, action: #selector(withdrawTapped), for: .touchUpInside)
Task { await viewModel.loadInitial(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()
}
}
}
private func applyState() {
summaryCard.apply(
withdrawable: viewModel.summary?.amountWithdrawable,
total: viewModel.summary?.amountTotal,
balance: viewModel.summary?.amountCurrentBalance,
points: viewModel.availablePoints
)
applyTabAppearance()
filterPanel.isHidden = viewModel.selectedTab != .transaction
filterPanel.apply(filter: viewModel.filter, totalIncome: viewModel.totalIncomeLabel, totalPoints: viewModel.totalPointsLabel)
refreshControl.endRefreshing()
applySnapshot()
if let message = viewModel.errorMessage, message != lastShownMessage {
lastShownMessage = message
showToast(message)
}
}
private func applyTabAppearance() {
let selectedColor = UIColor(hex: 0x1677FF)
let normalColor = UIColor(hex: 0x666666)
withdrawTabButton.setTitleColor(viewModel.selectedTab == .withdraw ? selectedColor : normalColor, for: .normal)
transactionTabButton.setTitleColor(viewModel.selectedTab == .transaction ? selectedColor : normalColor, for: .normal)
withdrawTabButton.titleLabel?.font = .systemFont(ofSize: 16, weight: viewModel.selectedTab == .withdraw ? .semibold : .regular)
transactionTabButton.titleLabel?.font = .systemFont(ofSize: 16, weight: viewModel.selectedTab == .transaction ? .semibold : .regular)
}
private func applySnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main])
if viewModel.selectedTab == .withdraw {
snapshot.appendItems(viewModel.withdrawRecords.map(Item.withdraw), 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)
}
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 = UIColor(hex: 0xB3B8C2)
label.font = .systemFont(ofSize: 14)
label.textAlignment = .center
tableView.backgroundView = label
}
private func presentFilterMenu() {
let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
WalletFilter.allCases.forEach { filter in
alert.addAction(UIAlertAction(title: filter.title, style: .default) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.selectFilter(filter, api: self.walletAPI) }
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
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 {
switch viewModel.selectedTab {
case .withdraw:
await viewModel.refreshSummary(api: walletAPI)
await viewModel.refreshWithdraw(api: walletAPI)
await viewModel.refreshPoints(api: walletAPI)
case .transaction:
await viewModel.refreshTransaction(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 = UIColor(hex: 0x1677FF)
layer.cornerRadius = 24
clipsToBounds = true
withdrawableTitle.text = "可提现金额"
withdrawableTitle.textColor = UIColor.white.withAlphaComponent(0.8)
withdrawableTitle.font = .systemFont(ofSize: 14)
withdrawableTitle.textAlignment = .center
withdrawableLabel.textColor = .white
withdrawableLabel.font = .systemFont(ofSize: 36, weight: .bold)
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(16)
make.leading.trailing.equalToSuperview().inset(24)
}
withdrawableLabel.snp.makeConstraints { make in
make.top.equalTo(withdrawableTitle.snp.bottom).offset(4)
make.leading.trailing.equalToSuperview().inset(24)
}
metricsStack.snp.makeConstraints { make in
make.top.equalTo(withdrawableLabel.snp.bottom).offset(18)
make.leading.trailing.equalToSuperview().inset(12)
make.bottom.equalToSuperview().inset(16)
}
}
@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 = .systemFont(ofSize: 14)
titleLabel.textAlignment = .center
valueLabel.textColor = .white
valueLabel.font = .systemFont(ofSize: 14, weight: .medium)
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 onFilterTap: (() -> 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.setTitle("\(filter.title)", for: .normal)
totalLabel.text = totalIncome.isEmpty ? "¥ 0.00" : totalIncome
pointsLabel.text = "积分+\(totalPoints)"
}
private func setupUI() {
backgroundColor = UIColor(hex: 0xF6F6F6)
layer.cornerRadius = 16
clipsToBounds = true
filterButton.setTitleColor(UIColor(hex: 0x111827), for: .normal)
filterButton.titleLabel?.font = .systemFont(ofSize: 14)
filterButton.contentHorizontalAlignment = .left
filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside)
totalTitleLabel.text = "总收益:"
totalTitleLabel.font = .systemFont(ofSize: 14, weight: .medium)
totalTitleLabel.textColor = .black
totalLabel.font = .systemFont(ofSize: 14, weight: .medium)
totalLabel.textColor = UIColor(hex: 0x0073FF)
pointsLabel.font = .systemFont(ofSize: 14, weight: .medium)
pointsLabel.textColor = UIColor(hex: 0xFF7B00)
pointsLabel.textAlignment = .right
addSubview(filterButton)
addSubview(totalTitleLabel)
addSubview(totalLabel)
addSubview(pointsLabel)
filterButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(8)
make.leading.equalToSuperview().offset(16)
make.width.lessThanOrEqualTo(150)
make.height.equalTo(32)
}
totalTitleLabel.snp.makeConstraints { make in
make.top.equalTo(filterButton.snp.bottom).offset(4)
make.leading.equalToSuperview().offset(16)
make.bottom.equalToSuperview().inset(12)
}
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)
}
}
@objc private func filterTapped() {
onFilterTap?()
}
}

View File

@ -0,0 +1,352 @@
//
// WithdrawViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android `WithdrawScreen`
final class WithdrawViewController: BaseViewController {
var onSubmitSuccess: (() -> Void)?
private let viewModel: WithdrawViewModel
private let walletAPI: any WalletPageServing
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let bottomBar = UIView()
private let submitButton = AppButton(title: "立即提现")
private let amountField = UITextField()
private let codeField = UITextField()
private let codeButton = UIButton(type: .system)
private let withdrawableLabel = UILabel()
private let limitLabel = UILabel()
private let phoneLabel = UILabel()
private let bankCardLabel = UILabel()
private let bankHolderLabel = UILabel()
private let settlementLabel = UILabel()
private let infoStack = UIStackView()
private var countdownTimer: Timer?
private var lastShownMessage: String?
///
init(
viewModel: WithdrawViewModel = WithdrawViewModel(),
walletAPI: any WalletPageServing = NetworkServices.shared.walletAPI
) {
self.viewModel = viewModel
self.walletAPI = walletAPI
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
countdownTimer?.invalidate()
}
override func setupNavigationBar() {
title = "提现设置"
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF5F6F8)
contentStack.axis = .vertical
contentStack.spacing = 16
bottomBar.backgroundColor = .white
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
view.addSubview(bottomBar)
bottomBar.addSubview(submitButton)
contentStack.addArrangedSubview(makeAmountCard())
contentStack.addArrangedSubview(makeBankCard())
contentStack.addArrangedSubview(makeSettlementCard())
contentStack.addArrangedSubview(makeInfoCard())
}
override func setupConstraints() {
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
submitButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16)
}
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
make.width.equalTo(scrollView).offset(-32)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyState() }
}
amountField.addTarget(self, action: #selector(amountChanged), for: .editingChanged)
codeField.addTarget(self, action: #selector(codeChanged), for: .editingChanged)
codeButton.addTarget(self, action: #selector(sendCodeTapped), for: .touchUpInside)
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
Task { await viewModel.load(api: walletAPI) }
}
private func applyState() {
amountField.text = viewModel.amount
codeField.text = viewModel.verificationCode
if let info = viewModel.withdrawInfo {
withdrawableLabel.text = WalletViewModel.formatAmount(info.amountWithdrawable)
limitLabel.text = "单笔最高提现 \(WalletViewModel.formatAmount(info.maxSingleWithdrawAmount)), 单日最高提现 \(WalletViewModel.formatAmount(info.maxDailyWithdrawAmount))"
phoneLabel.text = "验证码将发送至: \(maskPhone(info.userPhone))"
bankCardLabel.text = "\(info.bankCard.bankName) (尾号\(lastFour(info.bankCard.cardNumber)))"
bankHolderLabel.text = info.bankCard.realName
settlementLabel.text = info.settlement
applyInfoLines(info.withdrawInfo)
}
codeButton.setTitle(viewModel.countdown > 0 ? "\(viewModel.countdown)s" : "获取验证码", for: .normal)
codeButton.isEnabled = viewModel.countdown == 0
submitButton.isEnabled = viewModel.canSubmit
if viewModel.countdown > 0 { startCountdownTimerIfNeeded() }
showMessageIfNeeded(viewModel.statusMessage)
showMessageIfNeeded(viewModel.errorMessage)
}
private func makeAmountCard() -> UIView {
let card = makeCard()
let title = makeTitle("提现金额")
let row = UIStackView()
row.axis = .horizontal
row.alignment = .center
row.spacing = 16
let withdrawableTitle = makeBodyLabel("可提现金额", color: .black, weight: .medium)
withdrawableLabel.font = .systemFont(ofSize: 14, weight: .medium)
withdrawableLabel.textColor = UIColor(hex: 0x0073FF)
row.addArrangedSubview(withdrawableTitle)
row.addArrangedSubview(withdrawableLabel)
let amountContainer = borderedInputContainer()
amountField.placeholder = "请输入金额"
amountField.keyboardType = .decimalPad
amountField.font = .systemFont(ofSize: 14)
let allButton = UIButton(type: .system)
allButton.setTitle("全部提现", for: .normal)
allButton.setTitleColor(UIColor(hex: 0x0073FF), for: .normal)
allButton.titleLabel?.font = .systemFont(ofSize: 14)
allButton.addTarget(self, action: #selector(withdrawAllTapped), for: .touchUpInside)
amountContainer.addSubview(amountField)
amountContainer.addSubview(allButton)
amountField.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.top.bottom.equalToSuperview()
make.trailing.equalTo(allButton.snp.leading).offset(-8)
}
allButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(12)
make.centerY.equalToSuperview()
}
limitLabel.font = .systemFont(ofSize: 12)
limitLabel.textColor = UIColor(hex: 0x9CA3AF)
phoneLabel.font = .systemFont(ofSize: 12)
phoneLabel.textColor = UIColor(hex: 0x4B5563)
let codeRow = UIStackView()
codeRow.axis = .horizontal
codeRow.spacing = 12
let codeContainer = borderedInputContainer()
codeField.placeholder = "请输入验证码"
codeField.keyboardType = .numberPad
codeField.font = .systemFont(ofSize: 14)
codeContainer.addSubview(codeField)
codeField.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)) }
codeButton.setTitle("获取验证码", for: .normal)
codeButton.setTitleColor(UIColor(hex: 0x0073FF), for: .normal)
codeButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
codeButton.layer.borderWidth = 1
codeButton.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
codeButton.layer.cornerRadius = 4
codeRow.addArrangedSubview(codeContainer)
codeRow.addArrangedSubview(codeButton)
codeContainer.snp.makeConstraints { make in make.height.equalTo(46) }
codeButton.snp.makeConstraints { make in make.width.equalTo(100) }
let stack = UIStackView(arrangedSubviews: [title, row, amountContainer, limitLabel, phoneLabel, codeRow])
stack.axis = .vertical
stack.spacing = 12
card.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
amountContainer.snp.makeConstraints { make in make.height.equalTo(46) }
return card
}
private func makeBankCard() -> UIView {
let card = makeCard()
let title = makeTitle("银行卡信息")
let inner = makeInnerPanel()
bankCardLabel.font = .systemFont(ofSize: 14, weight: .medium)
bankCardLabel.textColor = UIColor(hex: 0x333333)
bankHolderLabel.font = .systemFont(ofSize: 14)
bankHolderLabel.textColor = UIColor(hex: 0x4B5563)
let innerStack = UIStackView(arrangedSubviews: [bankCardLabel, bankHolderLabel])
innerStack.axis = .vertical
innerStack.spacing = 12
inner.addSubview(innerStack)
innerStack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
let stack = UIStackView(arrangedSubviews: [title, inner])
stack.axis = .vertical
stack.spacing = 8
card.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
return card
}
private func makeSettlementCard() -> UIView {
let card = makeCard()
let title = makeTitle("预计到账时间")
let inner = makeInnerPanel()
settlementLabel.font = .systemFont(ofSize: 14)
settlementLabel.textColor = UIColor(hex: 0x4B5563)
settlementLabel.numberOfLines = 0
inner.addSubview(settlementLabel)
settlementLabel.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
let stack = UIStackView(arrangedSubviews: [title, inner])
stack.axis = .vertical
stack.spacing = 8
card.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
return card
}
private func makeInfoCard() -> UIView {
let card = makeCard()
let title = makeTitle("提现说明")
let inner = makeInnerPanel()
infoStack.axis = .vertical
infoStack.spacing = 4
inner.addSubview(infoStack)
infoStack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
let stack = UIStackView(arrangedSubviews: [title, inner])
stack.axis = .vertical
stack.spacing = 8
card.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
return card
}
private func makeCard() -> UIView {
let view = UIView()
view.backgroundColor = .white
view.layer.cornerRadius = 12
view.clipsToBounds = true
return view
}
private func makeInnerPanel() -> UIView {
let view = UIView()
view.backgroundColor = UIColor(hex: 0xF4F4F4)
view.layer.cornerRadius = 8
view.layer.borderWidth = 1
view.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
return view
}
private func borderedInputContainer() -> UIView {
let view = UIView()
view.layer.cornerRadius = 4
view.layer.borderWidth = 1
view.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
return view
}
private func makeTitle(_ text: String) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 18, weight: .medium)
label.textColor = .black
return label
}
private func makeBodyLabel(_ text: String, color: UIColor, weight: UIFont.Weight = .regular) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 14, weight: weight)
label.textColor = color
return label
}
private func applyInfoLines(_ lines: [String]) {
infoStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
lines.forEach { text in
let label = makeBodyLabel(text, color: UIColor(hex: 0x4B5563))
label.numberOfLines = 0
infoStack.addArrangedSubview(label)
}
}
private func showMessageIfNeeded(_ message: String?) {
guard let message, !message.isEmpty, message != lastShownMessage else { return }
lastShownMessage = message
showToast(message)
}
private func startCountdownTimerIfNeeded() {
guard countdownTimer == nil else { return }
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] timer in
guard let self else { return }
self.viewModel.decrementCountdown()
if self.viewModel.countdown == 0 {
timer.invalidate()
self.countdownTimer = nil
}
}
}
private func maskPhone(_ phone: String) -> String {
guard phone.count >= 7 else { return phone }
let prefix = phone.prefix(3)
let suffix = phone.suffix(4)
return "\(prefix)****\(suffix)"
}
private func lastFour(_ cardNumber: String) -> String {
String(cardNumber.suffix(4))
}
@objc private func amountChanged() {
viewModel.updateAmount(amountField.text ?? "")
}
@objc private func codeChanged() {
viewModel.updateVerificationCode(codeField.text ?? "")
}
@objc private func withdrawAllTapped() {
viewModel.withdrawAll()
}
@objc private func sendCodeTapped() {
Task { await viewModel.requestVerificationCode(api: walletAPI) }
}
@objc private func submitTapped() {
Task {
showLoading()
let success = await viewModel.submit(api: walletAPI)
hideLoading()
if success {
onSubmitSuccess?()
navigationController?.popViewController(animated: true)
}
}
}
}