完善实名认证、钱包与打卡点模块 UI 与业务逻辑。

对齐 Android 实名认证流程与审核页、钱包提现/积分兑换界面,优化打卡点列表与详情交互,并补充相关资源、主题 token 与单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 16:28:10 +08:00
parent 07b2b9d459
commit 5138c1c11a
63 changed files with 3101 additions and 745 deletions

View File

@ -16,6 +16,7 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
case withdraw(WalletWithdrawItem)
case transactionHeader(EarningDetailGroup)
case transaction(EarningDetailItem)
case loading
}
private let viewModel: WalletViewModel
@ -27,6 +28,7 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
private let tabStack = UIStackView()
private let withdrawTabButton = UIButton(type: .system)
private let transactionTabButton = UIButton(type: .system)
private let tabIndicator = UIView()
private let filterPanel = WalletFilterPanelView()
private let tableView = UITableView(frame: .zero, style: .plain)
private let refreshControl = UIRefreshControl()
@ -54,24 +56,30 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
}
override func setupNavigationBar() {
title = "我的钱包"
let titleLabel = UILabel()
titleLabel.text = "我的钱包"
titleLabel.textColor = WalletTokens.text333
titleLabel.font = UIFont.systemFont(ofSize: 18)
navigationItem.titleView = titleLabel
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF5F6F8)
view.backgroundColor = WalletTokens.pageBackground
contentCard.backgroundColor = .white
contentCard.layer.cornerRadius = 16
contentCard.layer.cornerRadius = WalletTokens.contentRadius
contentCard.clipsToBounds = true
tabStack.axis = .horizontal
tabStack.distribution = .fillEqually
[withdrawTabButton, transactionTabButton].forEach {
$0.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
$0.setTitleColor(UIColor(hex: 0x666666), for: .normal)
$0.titleLabel?.font = WalletTokens.tabSelectedFont
$0.setTitleColor(WalletTokens.tabNormal, for: .normal)
tabStack.addArrangedSubview($0)
}
withdrawTabButton.setTitle("提现记录", for: .normal)
transactionTabButton.setTitle("收益明细", for: .normal)
tabIndicator.backgroundColor = WalletTokens.summaryBackground
tabIndicator.layer.cornerRadius = 1.5
tableView.backgroundColor = .white
tableView.separatorStyle = .none
@ -79,15 +87,19 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 120
tableView.refreshControl = refreshControl
tableView.contentInset = UIEdgeInsets(top: WalletTokens.rowGap, left: 0, bottom: WalletTokens.rowGap, right: 0)
tableView.verticalScrollIndicatorInsets = tableView.contentInset
tableView.register(WalletWithdrawRecordCell.self, forCellReuseIdentifier: WalletWithdrawRecordCell.reuseIdentifier)
tableView.register(WalletTransactionHeaderCell.self, forCellReuseIdentifier: WalletTransactionHeaderCell.reuseIdentifier)
tableView.register(WalletTransactionRecordCell.self, forCellReuseIdentifier: WalletTransactionRecordCell.reuseIdentifier)
tableView.register(WalletLoadingCell.self, forCellReuseIdentifier: WalletLoadingCell.reuseIdentifier)
bottomBar.backgroundColor = .white
view.addSubview(summaryCard)
view.addSubview(contentCard)
contentCard.addSubview(tabStack)
contentCard.addSubview(tabIndicator)
contentCard.addSubview(filterPanel)
contentCard.addSubview(tableView)
view.addSubview(bottomBar)
@ -98,32 +110,41 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
override func setupConstraints() {
summaryCard.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.top.equalTo(view.safeAreaLayoutGuide).offset(WalletTokens.screenInset)
make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
withdrawButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.top.equalToSuperview().offset(WalletTokens.screenInset)
make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(22)
}
withdrawButton.layer.cornerRadius = WalletTokens.bottomButtonRadius
contentCard.snp.makeConstraints { make in
make.top.equalTo(summaryCard.snp.bottom).offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(bottomBar.snp.top).offset(-16)
make.top.equalTo(summaryCard.snp.bottom).offset(WalletTokens.cardGap)
make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset)
make.bottom.equalTo(bottomBar.snp.top).offset(-WalletTokens.cardGap)
}
tabStack.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.top.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(32)
make.height.equalTo(50)
}
tabIndicator.snp.makeConstraints { make in
make.width.equalTo(24)
make.height.equalTo(3)
make.bottom.equalTo(tabStack)
make.centerX.equalTo(withdrawTabButton)
}
filterPanel.snp.makeConstraints { make in
make.top.equalTo(tabStack.snp.bottom)
make.leading.trailing.equalToSuperview().inset(16)
make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset)
make.height.equalTo(0)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(filterPanel.snp.bottom).offset(4)
make.top.equalTo(filterPanel.snp.bottom).offset(WalletTokens.rowGap)
make.leading.trailing.bottom.equalToSuperview()
}
}
@ -139,13 +160,17 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
}
withdrawTabButton.addTarget(self, action: #selector(withdrawTabTapped), for: .touchUpInside)
transactionTabButton.addTarget(self, action: #selector(transactionTabTapped), for: .touchUpInside)
filterPanel.onFilterTap = { [weak self] in
self?.presentFilterMenu()
filterPanel.onFilterSelected = { [weak self] filter in
guard let self else { return }
Task { await self.viewModel.selectFilter(filter, api: self.walletAPI) }
}
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
withdrawButton.addTarget(self, action: #selector(withdrawTapped), for: .touchUpInside)
Task { await viewModel.loadInitial(api: walletAPI) }
Task { await viewModel.refreshSummary(api: walletAPI) }
Task { await viewModel.refreshWithdraw(api: walletAPI) }
Task { await viewModel.refreshTransaction(api: walletAPI) }
Task { await viewModel.refreshPoints(api: walletAPI) }
}
private func configureDataSource() {
@ -172,6 +197,11 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
) as? WalletTransactionRecordCell
cell?.apply(record)
return cell ?? UITableViewCell()
case .loading:
return tableView.dequeueReusableCell(
withIdentifier: WalletLoadingCell.reuseIdentifier,
for: indexPath
)
}
}
}
@ -184,9 +214,15 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
points: viewModel.availablePoints
)
applyTabAppearance()
filterPanel.isHidden = viewModel.selectedTab != .transaction
updateContentLayout()
filterPanel.apply(filter: viewModel.filter, totalIncome: viewModel.totalIncomeLabel, totalPoints: viewModel.totalPointsLabel)
refreshControl.endRefreshing()
let isRefreshing = switch viewModel.selectedTab {
case .withdraw: viewModel.withdrawRefreshing
case .transaction: viewModel.transactionRefreshing
}
if !isRefreshing {
refreshControl.endRefreshing()
}
applySnapshot()
if let message = viewModel.errorMessage, message != lastShownMessage {
lastShownMessage = message
@ -195,12 +231,35 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
}
private func applyTabAppearance() {
let selectedColor = UIColor(hex: 0x1677FF)
let normalColor = UIColor(hex: 0x666666)
let selectedColor = WalletTokens.summaryBackground
let normalColor = WalletTokens.tabNormal
withdrawTabButton.setTitleColor(viewModel.selectedTab == .withdraw ? selectedColor : normalColor, for: .normal)
transactionTabButton.setTitleColor(viewModel.selectedTab == .transaction ? selectedColor : normalColor, for: .normal)
withdrawTabButton.titleLabel?.font = .systemFont(ofSize: 16, weight: viewModel.selectedTab == .withdraw ? .semibold : .regular)
transactionTabButton.titleLabel?.font = .systemFont(ofSize: 16, weight: viewModel.selectedTab == .transaction ? .semibold : .regular)
withdrawTabButton.titleLabel?.font = viewModel.selectedTab == .withdraw ? WalletTokens.tabSelectedFont : WalletTokens.tabFont
transactionTabButton.titleLabel?.font = viewModel.selectedTab == .transaction ? WalletTokens.tabSelectedFont : WalletTokens.tabFont
tabIndicator.snp.remakeConstraints { make in
make.width.equalTo(24)
make.height.equalTo(3)
make.bottom.equalTo(tabStack)
make.centerX.equalTo(viewModel.selectedTab == .withdraw ? withdrawTabButton : transactionTabButton)
}
UIView.animate(withDuration: 0.2) {
self.contentCard.layoutIfNeeded()
}
}
private func updateContentLayout() {
let showsFilter = viewModel.selectedTab == .transaction
filterPanel.isHidden = !showsFilter
filterPanel.snp.remakeConstraints { make in
make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset)
if showsFilter {
make.top.equalTo(tabStack.snp.bottom).offset(WalletTokens.rowGap)
} else {
make.top.equalTo(tabStack.snp.bottom)
make.height.equalTo(0)
}
}
}
private func applySnapshot() {
@ -208,6 +267,9 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
snapshot.appendSections([.main])
if viewModel.selectedTab == .withdraw {
snapshot.appendItems(viewModel.withdrawRecords.map(Item.withdraw), toSection: .main)
if viewModel.withdrawLoading {
snapshot.appendItems([.loading], toSection: .main)
}
} else {
let items = viewModel.transactionEntries.map { entry -> Item in
switch entry {
@ -216,6 +278,9 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
}
}
snapshot.appendItems(items, toSection: .main)
if viewModel.transactionLoading {
snapshot.appendItems([.loading], toSection: .main)
}
}
dataSource.apply(snapshot, animatingDifferences: true)
updateEmptyState()
@ -236,24 +301,12 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
}
let label = UILabel()
label.text = emptyText
label.textColor = UIColor(hex: 0xB3B8C2)
label.font = .systemFont(ofSize: 14)
label.textColor = WalletTokens.textDisabled
label.font = WalletTokens.bodyFont
label.textAlignment = .center
tableView.backgroundView = label
}
private func presentFilterMenu() {
let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
WalletFilter.allCases.forEach { filter in
alert.addAction(UIAlertAction(title: filter.title, style: .default) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.selectFilter(filter, api: self.walletAPI) }
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
private func openPointsRedemption() {
navigationController?.pushViewController(PointsRedemptionViewController(), animated: true)
}
@ -270,9 +323,7 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
Task {
switch viewModel.selectedTab {
case .withdraw:
await viewModel.refreshSummary(api: walletAPI)
await viewModel.refreshWithdraw(api: walletAPI)
await viewModel.refreshPoints(api: walletAPI)
case .transaction:
await viewModel.refreshTransaction(api: walletAPI)
}
@ -363,17 +414,17 @@ private final class WalletSummaryCardView: UIView {
}
private func setupUI() {
backgroundColor = UIColor(hex: 0x1677FF)
layer.cornerRadius = 24
backgroundColor = WalletTokens.summaryBackground
layer.cornerRadius = WalletTokens.summaryRadius
clipsToBounds = true
withdrawableTitle.text = "可提现金额"
withdrawableTitle.textColor = UIColor.white.withAlphaComponent(0.8)
withdrawableTitle.font = .systemFont(ofSize: 14)
withdrawableTitle.font = WalletTokens.bodyFont
withdrawableTitle.textAlignment = .center
withdrawableLabel.textColor = .white
withdrawableLabel.font = .systemFont(ofSize: 36, weight: .bold)
withdrawableLabel.font = WalletTokens.displayFont
withdrawableLabel.textAlignment = .center
withdrawableLabel.adjustsFontSizeToFitWidth = true
withdrawableLabel.minimumScaleFactor = 0.7
@ -388,17 +439,17 @@ private final class WalletSummaryCardView: UIView {
addSubview(metricsStack)
withdrawableTitle.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(24)
make.top.equalToSuperview().offset(WalletTokens.cardPadding)
make.leading.trailing.equalToSuperview().inset(WalletTokens.summaryHorizontalPadding)
}
withdrawableLabel.snp.makeConstraints { make in
make.top.equalTo(withdrawableTitle.snp.bottom).offset(4)
make.leading.trailing.equalToSuperview().inset(24)
make.top.equalTo(withdrawableTitle.snp.bottom).offset(WalletTokens.tinyGap)
make.leading.trailing.equalToSuperview().inset(WalletTokens.summaryHorizontalPadding)
}
metricsStack.snp.makeConstraints { make in
make.top.equalTo(withdrawableLabel.snp.bottom).offset(18)
make.leading.trailing.equalToSuperview().inset(12)
make.bottom.equalToSuperview().inset(16)
make.leading.trailing.equalToSuperview().inset(WalletTokens.summaryHorizontalPadding)
make.bottom.equalToSuperview().inset(WalletTokens.cardPadding)
}
}
@ -432,11 +483,11 @@ private final class WalletSummaryMetricView: UIControl {
private func setupUI() {
titleLabel.textColor = UIColor.white.withAlphaComponent(0.8)
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.font = WalletTokens.bodyFont
titleLabel.textAlignment = .center
valueLabel.textColor = .white
valueLabel.font = .systemFont(ofSize: 14, weight: .medium)
valueLabel.font = WalletTokens.bodyMediumFont
valueLabel.textAlignment = .center
valueLabel.adjustsFontSizeToFitWidth = true
valueLabel.minimumScaleFactor = 0.75
@ -469,7 +520,7 @@ private final class WalletSummaryMetricView: UIControl {
///
private final class WalletFilterPanelView: UIView {
var onFilterTap: (() -> Void)?
var onFilterSelected: ((WalletFilter) -> Void)?
private let filterButton = UIButton(type: .system)
private let totalTitleLabel = UILabel()
@ -487,30 +538,40 @@ private final class WalletFilterPanelView: UIView {
}
func apply(filter: WalletFilter, totalIncome: String, totalPoints: Int) {
filterButton.setTitle("\(filter.title)", for: .normal)
filterButton.configuration?.title = filter.title
configureMenu(selected: filter)
totalLabel.text = totalIncome.isEmpty ? "¥ 0.00" : totalIncome
pointsLabel.text = "积分+\(totalPoints)"
}
private func setupUI() {
backgroundColor = UIColor(hex: 0xF6F6F6)
layer.cornerRadius = 16
backgroundColor = WalletTokens.filterBackground
layer.cornerRadius = WalletTokens.contentRadius
clipsToBounds = true
filterButton.setTitleColor(UIColor(hex: 0x111827), for: .normal)
filterButton.titleLabel?.font = .systemFont(ofSize: 14)
filterButton.titleLabel?.font = WalletTokens.bodyFont
filterButton.contentHorizontalAlignment = .left
filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside)
filterButton.backgroundColor = WalletTokens.inputFocusedBackground
filterButton.layer.cornerRadius = WalletTokens.contentRadius
filterButton.showsMenuAsPrimaryAction = true
filterButton.changesSelectionAsPrimaryAction = false
filterButton.configuration = .plain()
filterButton.configuration?.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 12)
filterButton.configuration?.image = UIImage(systemName: "chevron.down")
filterButton.configuration?.imagePlacement = .trailing
filterButton.configuration?.imagePadding = 8
filterButton.tintColor = WalletTokens.text333
totalTitleLabel.text = "总收益:"
totalTitleLabel.font = .systemFont(ofSize: 14, weight: .medium)
totalTitleLabel.font = WalletTokens.bodyMediumFont
totalTitleLabel.textColor = .black
totalLabel.font = .systemFont(ofSize: 14, weight: .medium)
totalLabel.textColor = UIColor(hex: 0x0073FF)
totalLabel.font = WalletTokens.bodyMediumFont
totalLabel.textColor = WalletTokens.primary
pointsLabel.font = .systemFont(ofSize: 14, weight: .medium)
pointsLabel.textColor = UIColor(hex: 0xFF7B00)
pointsLabel.font = WalletTokens.bodyMediumFont
pointsLabel.textColor = WalletTokens.points
pointsLabel.textAlignment = .right
addSubview(filterButton)
@ -519,15 +580,14 @@ private final class WalletFilterPanelView: UIView {
addSubview(pointsLabel)
filterButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(8)
make.leading.equalToSuperview().offset(16)
make.width.lessThanOrEqualTo(150)
make.height.equalTo(32)
make.top.leading.equalToSuperview()
make.width.lessThanOrEqualTo(WalletTokens.filterFieldMaxWidth)
make.height.equalTo(WalletTokens.filterFieldHeight)
}
totalTitleLabel.snp.makeConstraints { make in
make.top.equalTo(filterButton.snp.bottom).offset(4)
make.leading.equalToSuperview().offset(16)
make.bottom.equalToSuperview().inset(12)
make.top.equalTo(filterButton.snp.bottom)
make.leading.equalToSuperview().offset(WalletTokens.cardPadding)
make.bottom.equalToSuperview().inset(WalletTokens.rowGap)
}
totalLabel.snp.makeConstraints { make in
make.centerY.equalTo(totalTitleLabel)
@ -540,7 +600,15 @@ private final class WalletFilterPanelView: UIView {
}
}
@objc private func filterTapped() {
onFilterTap?()
private func configureMenu(selected: WalletFilter) {
let actions = WalletFilter.allCases.map { [weak self] filter in
UIAction(
title: filter.title,
state: filter == selected ? .on : .off
) { _ in
self?.onFilterSelected?(filter)
}
}
filterButton.menu = UIMenu(children: actions)
}
}