// // 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! 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(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() 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) } }