Files
suixinkan_uikit/suixinkan/UI/Wallet/WithdrawViewController.swift
汉秋 5138c1c11a 完善实名认证、钱包与打卡点模块 UI 与业务逻辑。
对齐 Android 实名认证流程与审核页、钱包提现/积分兑换界面,优化打卡点列表与详情交互,并补充相关资源、主题 token 与单元测试。

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

394 lines
15 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.

//
// 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() {
let titleLabel = UILabel()
titleLabel.text = "提现设置"
titleLabel.textColor = WalletTokens.text333
titleLabel.font = UIFont.systemFont(ofSize: 18)
navigationItem.titleView = titleLabel
}
override func setupUI() {
view.backgroundColor = WalletTokens.withdrawPageBackground
contentStack.axis = .vertical
contentStack.spacing = WalletTokens.cardGap
contentStack.backgroundColor = .white
contentStack.layer.cornerRadius = WalletTokens.cardRadius
contentStack.clipsToBounds = true
bottomBar.backgroundColor = .white
bottomBar.isHidden = true
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(WalletTokens.screenInset)
make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(WalletTokens.screenInset)
}
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(WalletTokens.screenInset)
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 {
showLoading()
await viewModel.load(api: walletAPI)
hideLoading()
if viewModel.withdrawInfo == nil, let message = viewModel.errorMessage {
navigationController?.popViewController(animated: true)
(navigationController?.topViewController as? BaseViewController)?.showToast(message)
}
}
}
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
updateBottomBarVisibility(hasContent: viewModel.withdrawInfo != nil && !viewModel.loading)
if viewModel.countdown > 0 { startCountdownTimerIfNeeded() }
showMessageIfNeeded(viewModel.statusMessage)
if viewModel.withdrawInfo != nil {
showMessageIfNeeded(viewModel.errorMessage)
}
}
private func updateBottomBarVisibility(hasContent: Bool) {
bottomBar.isHidden = !hasContent
scrollView.snp.remakeConstraints { make in
make.top.leading.trailing.equalToSuperview()
if hasContent {
make.bottom.equalTo(bottomBar.snp.top)
} else {
make.bottom.equalToSuperview()
}
}
}
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 = WalletTokens.bodyMediumFont
withdrawableLabel.textColor = WalletTokens.primary
row.addArrangedSubview(withdrawableTitle)
row.addArrangedSubview(withdrawableLabel)
let amountContainer = borderedInputContainer()
amountField.placeholder = "请输入金额"
amountField.keyboardType = .decimalPad
amountField.font = WalletTokens.bodyFont
let allButton = UIButton(type: .system)
allButton.setTitle("全部提现", for: .normal)
allButton.setTitleColor(WalletTokens.primary, for: .normal)
allButton.titleLabel?.font = WalletTokens.bodyFont
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 = WalletTokens.captionFont
limitLabel.textColor = WalletTokens.textTertiary
phoneLabel.font = WalletTokens.captionFont
phoneLabel.textColor = WalletTokens.textSecondary
let codeRow = UIStackView()
codeRow.axis = .horizontal
codeRow.spacing = 12
let codeContainer = borderedInputContainer()
codeField.placeholder = "请输入验证码"
codeField.keyboardType = .numberPad
codeField.font = WalletTokens.bodyFont
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(WalletTokens.primary, for: .normal)
codeButton.setTitleColor(WalletTokens.textDisabled, for: .disabled)
codeButton.titleLabel?.font = WalletTokens.bodyMediumFont
codeButton.layer.borderWidth = 1
codeButton.layer.borderColor = WalletTokens.border.cgColor
codeButton.layer.cornerRadius = WalletTokens.chipRadius
codeRow.addArrangedSubview(codeContainer)
codeRow.addArrangedSubview(codeButton)
codeContainer.snp.makeConstraints { make in make.height.equalTo(WalletTokens.inputHeight) }
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(WalletTokens.inputHeight) }
return card
}
private func makeBankCard() -> UIView {
let card = makeCard()
let title = makeTitle("银行卡信息", size: 16)
let inner = makeInnerPanel()
bankCardLabel.font = WalletTokens.bodyMediumFont
bankCardLabel.textColor = WalletTokens.text333
bankHolderLabel.font = WalletTokens.bodyFont
bankHolderLabel.textColor = WalletTokens.textSecondary
let innerStack = UIStackView(arrangedSubviews: [bankCardLabel, bankHolderLabel])
innerStack.axis = .vertical
innerStack.spacing = 12
inner.addSubview(innerStack)
innerStack.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(WalletTokens.compactGap)
make.leading.trailing.equalToSuperview().inset(WalletTokens.cardPadding)
}
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("预计到账时间", size: 16)
let inner = makeInnerPanel()
settlementLabel.font = WalletTokens.bodyFont
settlementLabel.textColor = WalletTokens.textSecondary
settlementLabel.numberOfLines = 0
inner.addSubview(settlementLabel)
settlementLabel.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(WalletTokens.compactGap)
make.leading.trailing.equalToSuperview().inset(WalletTokens.cardPadding)
}
let stack = UIStackView(arrangedSubviews: [title, inner])
stack.axis = .vertical
stack.spacing = WalletTokens.tinyGap
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("提现说明", size: 16)
let inner = makeInnerPanel()
infoStack.axis = .vertical
infoStack.spacing = 4
inner.addSubview(infoStack)
infoStack.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(WalletTokens.compactGap)
make.leading.trailing.equalToSuperview().inset(WalletTokens.cardPadding)
}
let stack = UIStackView(arrangedSubviews: [title, inner])
stack.axis = .vertical
stack.spacing = WalletTokens.rowGap
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 = WalletTokens.cardRadius
view.clipsToBounds = true
return view
}
private func makeInnerPanel() -> UIView {
let view = UIView()
view.backgroundColor = WalletTokens.inputBackground
view.layer.cornerRadius = WalletTokens.innerRadius
view.layer.borderWidth = 1
view.layer.borderColor = WalletTokens.border.cgColor
return view
}
private func borderedInputContainer() -> UIView {
let view = UIView()
view.layer.cornerRadius = WalletTokens.chipRadius
view.layer.borderWidth = 1
view.layer.borderColor = WalletTokens.border.cgColor
return view
}
private func makeTitle(_ text: String, size: CGFloat = 18) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: size, 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: WalletTokens.textSecondary)
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)
}
}
}
}