对齐 Android 实名认证流程与审核页、钱包提现/积分兑换界面,优化打卡点列表与详情交互,并补充相关资源、主题 token 与单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
394 lines
15 KiB
Swift
394 lines
15 KiB
Swift
//
|
||
// 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)
|
||
}
|
||
}
|
||
}
|
||
}
|