// // 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! private var tableHeightConstraint: Constraint? 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() { 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(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() 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) } }