Files
suixinkan_uikit/suixinkan/UI/Wallet/PointsRedemptionViewController.swift
汉秋 6336feff27 完善景区排队设置页与全局按钮配置迁移。
重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 16:21:53 +08:00

531 lines
22 KiB
Swift
Raw Permalink 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.

//
// 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 summaryLoadingIndicator = UIActivityIndicatorView(style: .large)
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 tableHeightConstraint: Constraint?
private var lastShownMessage: String?
///
init(
viewModel: PointsRedemptionViewModel = PointsRedemptionViewModel(),
walletAPI: (any WalletPageServing)? = nil
) {
self.viewModel = viewModel
self.walletAPI = walletAPI ?? NetworkServices.shared.walletAPI
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
contentStack.axis = .vertical
contentStack.spacing = WalletTokens.cardGap
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(WalletTokens.screenInset)
make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(22)
}
submitButton.layer.cornerRadius = WalletTokens.bottomButtonRadius
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)
}
tableView.snp.makeConstraints { make in
tableHeightConstraint = make.height.equalTo(200).constraint
}
}
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.refreshOverview(api: walletAPI) }
Task { await viewModel.refreshWithdrawList(api: walletAPI) }
}
private func configureTable() {
tableView.backgroundColor = .white
tableView.separatorStyle = .none
tableView.delegate = self
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 120
scrollView.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)"
pointsValueLabel.isHidden = viewModel.overviewLoading
if viewModel.overviewLoading {
summaryLoadingIndicator.startAnimating()
} else {
summaryLoadingIndicator.stopAnimating()
}
pointsInputField.text = viewModel.pointsInput
submitButton.isEnabled = (Int(viewModel.pointsInput) ?? 0) > 0
if !viewModel.withdrawRefreshing, !viewModel.overviewLoading {
refreshControl.endRefreshing()
}
var snapshot = NSDiffableDataSourceSnapshot<Section, PointWithdrawItem>()
snapshot.appendSections([.main])
snapshot.appendItems(viewModel.withdrawRecords, toSection: .main)
dataSource.apply(snapshot, animatingDifferences: true) { [weak self] in
self?.updateTablePresentation()
}
showMessageIfNeeded(viewModel.statusMessage)
showMessageIfNeeded(viewModel.errorMessage)
}
private func updateTablePresentation() {
tableView.layoutIfNeeded()
let isEmpty = viewModel.withdrawRecords.isEmpty
updateLoadingFooter()
guard isEmpty, !viewModel.withdrawLoading else {
tableView.backgroundView = nil
let minimumHeight: CGFloat = viewModel.withdrawLoading ? 56 : 44
let height = max(minimumHeight, min(tableView.contentSize.height, 400))
tableHeightConstraint?.update(offset: height)
tableView.isScrollEnabled = tableView.contentSize.height > 400
return
}
let label = UILabel()
label.text = "暂无提现记录"
label.font = WalletTokens.bodyFont
label.textColor = WalletTokens.textDisabled
label.textAlignment = .center
tableView.backgroundView = label
tableHeightConstraint?.update(offset: 200)
tableView.isScrollEnabled = false
}
private func updateLoadingFooter() {
guard viewModel.withdrawLoading else {
tableView.tableFooterView = UIView(frame: .zero)
return
}
let footer = UIView(frame: CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 56))
let indicator = UIActivityIndicatorView(style: .medium)
indicator.color = WalletTokens.primary
indicator.startAnimating()
footer.addSubview(indicator)
indicator.snp.makeConstraints { make in make.center.equalToSuperview() }
tableView.tableFooterView = footer
}
private func makeSummaryCard() -> UIView {
pointsCard.backgroundColor = WalletTokens.summaryBackground
pointsCard.layer.cornerRadius = WalletTokens.contentRadius
pointsCard.clipsToBounds = true
let title = UILabel()
title.text = "可提现积分"
title.font = WalletTokens.bodyFont
title.textColor = UIColor.white.withAlphaComponent(0.8)
title.textAlignment = .center
pointsValueLabel.font = WalletTokens.displayFont
pointsValueLabel.textColor = .white
pointsValueLabel.textAlignment = .center
pointsCard.addSubview(title)
pointsCard.addSubview(pointsValueLabel)
summaryLoadingIndicator.color = .white
summaryLoadingIndicator.hidesWhenStopped = true
pointsCard.addSubview(summaryLoadingIndicator)
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)
}
summaryLoadingIndicator.snp.makeConstraints { make in
make.center.equalTo(pointsValueLabel)
}
return pointsCard
}
private func makeInputCard() -> UIView {
inputCard.backgroundColor = .white
inputCard.layer.cornerRadius = WalletTokens.cardRadius
inputCard.clipsToBounds = true
let title = makeTitle("积分提现", size: 18)
let titleSpacer = UIView()
titleSpacer.snp.makeConstraints { make in make.height.equalTo(WalletTokens.compactGap) }
let row = UIStackView()
row.axis = .horizontal
row.spacing = 16
let label = makeBody("可提现积分", color: .black, weight: .medium)
withdrawablePointsLabel.font = WalletTokens.bodyMediumFont
withdrawablePointsLabel.textColor = WalletTokens.points
row.addArrangedSubview(label)
row.addArrangedSubview(withdrawablePointsLabel)
let inputContainer = UIView()
inputContainer.layer.cornerRadius = WalletTokens.chipRadius
inputContainer.layer.borderWidth = 1
inputContainer.layer.borderColor = WalletTokens.border.cgColor
pointsInputField.placeholder = "请输入积分"
pointsInputField.keyboardType = .numberPad
pointsInputField.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)
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(WalletTokens.inputHeight) }
let ratio = makeBody("积分金额兑换比例12积分=1元人民币", color: WalletTokens.danger)
ratio.font = WalletTokens.captionFont
let stack = UIStackView(arrangedSubviews: [title, titleSpacer, row, inputContainer, ratio])
stack.axis = .vertical
stack.spacing = WalletTokens.compactGap
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 = WalletTokens.cardRadius
recordCard.clipsToBounds = true
let title = makeTitle("积分提现记录", size: 14)
title.textColor = WalletTokens.primary
title.font = UIFont.systemFont(ofSize: 14, weight: .bold)
title.textAlignment = .center
recordCard.addSubview(title)
recordCard.addSubview(tableView)
title.snp.makeConstraints { make in
make.top.equalToSuperview().offset(WalletTokens.cardPadding)
make.leading.trailing.equalToSuperview().inset(WalletTokens.cardPadding)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(title.snp.bottom).offset(WalletTokens.rowGap)
make.leading.trailing.bottom.equalToSuperview().inset(WalletTokens.cardPadding)
}
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.refreshOverview(api: walletAPI) }
Task { await viewModel.refreshWithdrawList(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 percentLabel = UILabel()
private let stepsStack = UIStackView()
private let infoLabel = UILabel()
private let statusLabel = UILabel()
private let remarkLabel = 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 display = PointWithdrawStatusDisplay(status: record.status)
let style = statusStyle(for: display.tone)
statusLabel.text = display.title
statusLabel.textColor = style.text
statusLabel.backgroundColor = style.background
let progress = display.progress
progressView.progress = Float(progress) / 100.0
progressView.progressTintColor = style.progress
progressView.isHidden = progress == 0
percentLabel.isHidden = progress == 0
stepsStack.isHidden = progress == 0
percentLabel.text = "\(progress)%"
configureSteps(progress: progress, tint: style.progress)
infoLabel.text = infoText(for: record)
remarkLabel.text = record.rejectReason.isEmpty ? "" : "备注信息:\(record.rejectReason)"
remarkLabel.isHidden = record.rejectReason.isEmpty
updateDynamicConstraints(progress: progress, showsRemark: !record.rejectReason.isEmpty)
}
private func setupUI() {
selectionStyle = .none
backgroundColor = .white
cardView.backgroundColor = .white
cardView.layer.cornerRadius = WalletTokens.contentRadius
cardView.layer.borderWidth = 1
cardView.layer.borderColor = WalletTokens.cardBorder.cgColor
cardView.clipsToBounds = true
pointsLabel.font = WalletTokens.metricMediumFont
pointsLabel.textColor = WalletTokens.textPrimary
amountLabel.font = WalletTokens.metricMediumFont
amountLabel.textColor = WalletTokens.danger
amountLabel.textAlignment = .right
timeLabel.font = WalletTokens.bodyFont
timeLabel.textColor = WalletTokens.textMuted
progressView.trackTintColor = WalletTokens.progressTrack
progressView.layer.cornerRadius = 3
progressView.clipsToBounds = true
percentLabel.font = WalletTokens.bodyMediumFont
percentLabel.textColor = WalletTokens.textTertiary
percentLabel.textAlignment = .right
stepsStack.axis = .horizontal
stepsStack.distribution = .equalSpacing
statusLabel.font = WalletTokens.captionFont
statusLabel.textAlignment = .center
statusLabel.layer.cornerRadius = WalletTokens.chipRadius
statusLabel.clipsToBounds = true
infoLabel.font = WalletTokens.bodyFont
infoLabel.textColor = WalletTokens.textSecondary
infoLabel.numberOfLines = 0
remarkLabel.font = WalletTokens.bodyFont
remarkLabel.textColor = WalletTokens.danger
remarkLabel.numberOfLines = 0
contentView.addSubview(cardView)
[pointsLabel, amountLabel, timeLabel, progressView, percentLabel, stepsStack, infoLabel, statusLabel, remarkLabel].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.equalToSuperview().offset(WalletTokens.cardPadding)
make.trailing.equalTo(percentLabel.snp.leading).offset(-WalletTokens.rowGap)
make.height.equalTo(WalletTokens.progressHeight)
}
percentLabel.snp.makeConstraints { make in
make.centerY.equalTo(progressView)
make.trailing.equalToSuperview().inset(WalletTokens.cardPadding)
}
stepsStack.snp.makeConstraints { make in
make.top.equalTo(progressView.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(16)
}
statusLabel.snp.makeConstraints { make in
make.centerY.equalTo(infoLabel)
make.trailing.equalToSuperview().inset(16)
make.height.equalTo(22)
make.width.greaterThanOrEqualTo(70)
}
updateDynamicConstraints(progress: 0, showsRemark: false)
}
private func updateDynamicConstraints(progress: Int, showsRemark: Bool) {
infoLabel.snp.remakeConstraints { make in
if progress > 0 {
make.top.equalTo(stepsStack.snp.bottom).offset(WalletTokens.compactGap)
} else {
make.top.equalTo(timeLabel.snp.bottom).offset(WalletTokens.compactGap)
}
make.leading.equalToSuperview().offset(WalletTokens.cardPadding)
make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-WalletTokens.compactGap)
if !showsRemark {
make.bottom.equalToSuperview().inset(14)
}
}
remarkLabel.snp.remakeConstraints { make in
make.top.equalTo(infoLabel.snp.bottom).offset(WalletTokens.tinyGap)
make.leading.trailing.equalToSuperview().inset(WalletTokens.cardPadding)
if showsRemark {
make.bottom.equalToSuperview().inset(14)
}
}
}
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 = WalletTokens.stepFont
label.textColor = progress >= Int((Float(index) / 3.0) * 100) ? tint : WalletTokens.textMuted
stepsStack.addArrangedSubview(label)
}
}
private func statusStyle(for tone: WalletStatusTone) -> (background: UIColor, text: UIColor, progress: UIColor) {
switch tone {
case .success:
(WalletTokens.successBackground, WalletTokens.success, WalletTokens.summaryBackground)
case .warning:
(WalletTokens.pointsPendingBackground, WalletTokens.points, WalletTokens.summaryBackground)
case .danger:
(WalletTokens.dangerBackground, WalletTokens.danger, WalletTokens.progressDanger)
case .info:
(WalletTokens.infoBackground, WalletTokens.summaryBackground, WalletTokens.summaryBackground)
}
}
private func infoText(for record: PointWithdrawItem) -> String {
let display = PointWithdrawStatusDisplay(status: record.status)
let value: String
switch record.status {
case 3: value = record.reviewedAt
case 2: value = record.paymentTime
default: value = record.estimatedReceiptTime
}
return "\(display.timeTitle)\(value.isEmpty ? "暂无" : value)"
}
private func formatAmount(_ value: Double?) -> String {
guard let value else { return "¥0.00" }
return String(format: "¥%.2f", value)
}
}