// // InviteRecordViewController.swift // suixinkan // import SnapKit import UIKit /// 邀请记录页面,展示邀请用户列表、奖励流水和钱包汇总金额。 final class InviteRecordViewController: BaseViewController { private enum Section: Hashable { case main } private enum Item: Hashable { case invite(InviteDisplayRow) case reward(WalletTransactionItem) case footer(String) } private let viewModel = InviteRecordViewModel() private let inviteAPI: InviteServing private let walletAPI: WalletServing private let headerView = UIView() private let totalAmountLabel = UILabel() private let withdrawableAmountLabel = UILabel() private let contentCard = UIView() private let tabStack = UIStackView() private let inviteTabButton = InviteRecordTabButton(title: "邀请记录") private let rewardTabButton = InviteRecordTabButton(title: "奖励记录") private let tableView = UITableView(frame: .zero, style: .plain) private let emptyLabel = UILabel() private let bottomBar = UIView() private let walletButton = UIButton(type: .system) private var dataSource: UITableViewDiffableDataSource! /// 初始化邀请记录页面。 init( inviteAPI: InviteServing? = nil, walletAPI: WalletServing? = nil ) { self.inviteAPI = inviteAPI ?? NetworkServices.shared.inviteAPI 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() { title = "邀请记录" } override func setupUI() { view.backgroundColor = UIColor(hex: 0xF5F6F8) setupHeader() setupContentCard() setupBottomBar() dataSource = makeDataSource() } override func setupConstraints() { headerView.snp.makeConstraints { make in make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) make.height.equalTo(104) } bottomBar.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } contentCard.snp.makeConstraints { make in make.top.equalTo(headerView.snp.bottom).offset(16) make.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(16) make.bottom.equalTo(bottomBar.snp.top).offset(-16) } } override func bindActions() { inviteTabButton.addTarget(self, action: #selector(inviteTabTapped), for: .touchUpInside) rewardTabButton.addTarget(self, action: #selector(rewardTabTapped), for: .touchUpInside) walletButton.addTarget(self, action: #selector(walletTapped), for: .touchUpInside) tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyViewModel(animated: true) } } } override func viewDidLoad() { super.viewDidLoad() Task { await viewModel.loadInitial(inviteAPI: inviteAPI, walletAPI: walletAPI) } } private func setupHeader() { headerView.backgroundColor = AppColor.primary view.addSubview(headerView) let divider = UIView() divider.backgroundColor = UIColor.white.withAlphaComponent(0.8) let totalMetric = makeMetricView(title: "累计奖励金额", valueLabel: totalAmountLabel) let withdrawMetric = makeMetricView(title: "可提现金额", valueLabel: withdrawableAmountLabel) headerView.addSubview(totalMetric) headerView.addSubview(divider) headerView.addSubview(withdrawMetric) totalMetric.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview() make.trailing.equalTo(divider.snp.leading) make.width.equalTo(withdrawMetric) } divider.snp.makeConstraints { make in make.centerX.equalToSuperview() make.centerY.equalToSuperview() make.width.equalTo(1) make.height.equalTo(56) } withdrawMetric.snp.makeConstraints { make in make.leading.equalTo(divider.snp.trailing) make.trailing.top.bottom.equalToSuperview() } } private func setupContentCard() { contentCard.backgroundColor = .white contentCard.layer.cornerRadius = 12 contentCard.clipsToBounds = true view.addSubview(contentCard) tabStack.axis = .horizontal tabStack.distribution = .fillEqually tabStack.addArrangedSubview(inviteTabButton) tabStack.addArrangedSubview(rewardTabButton) contentCard.addSubview(tabStack) tableView.backgroundColor = .white tableView.separatorStyle = .none tableView.delegate = self tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 72 tableView.refreshControl = UIRefreshControl() tableView.register(InviteUserCell.self, forCellReuseIdentifier: InviteUserCell.reuseIdentifier) tableView.register(InviteRewardCell.self, forCellReuseIdentifier: InviteRewardCell.reuseIdentifier) tableView.register(InviteFooterCell.self, forCellReuseIdentifier: InviteFooterCell.reuseIdentifier) contentCard.addSubview(tableView) emptyLabel.textColor = AppColor.textSecondary emptyLabel.font = .app(.body) emptyLabel.textAlignment = .center emptyLabel.isHidden = true contentCard.addSubview(emptyLabel) tabStack.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview().inset(16) make.height.equalTo(44) } tableView.snp.makeConstraints { make in make.top.equalTo(tabStack.snp.bottom).offset(8) make.leading.trailing.bottom.equalToSuperview().inset(16) } emptyLabel.snp.makeConstraints { make in make.center.equalTo(tableView) } } private func setupBottomBar() { bottomBar.backgroundColor = .white view.addSubview(bottomBar) walletButton.backgroundColor = AppColor.primary walletButton.layer.cornerRadius = 12 walletButton.setTitle("去钱包提现", for: .normal) walletButton.setTitleColor(.white, for: .normal) walletButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) bottomBar.addSubview(walletButton) walletButton.snp.makeConstraints { make in make.top.equalToSuperview().offset(16) make.leading.trailing.equalTo(bottomBar.safeAreaLayoutGuide).inset(16) make.height.equalTo(48) make.bottom.equalTo(bottomBar.safeAreaLayoutGuide).inset(22) } } private func makeMetricView(title: String, valueLabel: UILabel) -> UIView { let titleLabel = UILabel() titleLabel.text = title titleLabel.font = .app(.body) titleLabel.textColor = UIColor.white.withAlphaComponent(0.8) titleLabel.textAlignment = .center valueLabel.font = .systemFont(ofSize: 24, weight: .medium) valueLabel.textColor = .white valueLabel.textAlignment = .center valueLabel.adjustsFontSizeToFitWidth = true valueLabel.minimumScaleFactor = 0.7 let stack = UIStackView(arrangedSubviews: [titleLabel, valueLabel]) stack.axis = .vertical stack.alignment = .fill stack.spacing = 4 let container = UIView() container.addSubview(stack) stack.snp.makeConstraints { make in make.center.equalToSuperview() make.leading.trailing.equalToSuperview().inset(12) } return container } private func makeDataSource() -> UITableViewDiffableDataSource { UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item in switch item { case .invite(let row): let cell = tableView.dequeueReusableCell(withIdentifier: InviteUserCell.reuseIdentifier, for: indexPath) as! InviteUserCell cell.apply(row) return cell case .reward(let row): let cell = tableView.dequeueReusableCell(withIdentifier: InviteRewardCell.reuseIdentifier, for: indexPath) as! InviteRewardCell cell.apply(row) return cell case .footer(let text): let cell = tableView.dequeueReusableCell(withIdentifier: InviteFooterCell.reuseIdentifier, for: indexPath) as! InviteFooterCell cell.apply(text: text) return cell } } } @MainActor private func applyViewModel(animated: Bool) { totalAmountLabel.text = "¥ \(viewModel.totalRewardText)" withdrawableAmountLabel.text = "¥ \(viewModel.withdrawableText)" inviteTabButton.isSelected = viewModel.selectedTab == .invite rewardTabButton.isSelected = viewModel.selectedTab == .reward var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) switch viewModel.selectedTab { case .invite: snapshot.appendItems([.footer("已邀请摄影师")], toSection: .main) snapshot.appendItems(viewModel.inviteRows.map(Item.invite), toSection: .main) if !viewModel.inviteRows.isEmpty { snapshot.appendItems([.footer(viewModel.canLoadMoreInvite ? "加载中..." : "没有更多")], toSection: .main) } emptyLabel.text = "暂无邀请记录" emptyLabel.isHidden = !viewModel.inviteRows.isEmpty || viewModel.isInviteRefreshing case .reward: snapshot.appendItems([.footer("奖励记录")], toSection: .main) snapshot.appendItems(viewModel.rewardRows.map(Item.reward), toSection: .main) if !viewModel.rewardRows.isEmpty { snapshot.appendItems([.footer(viewModel.canLoadMoreReward ? "加载中..." : "没有更多")], toSection: .main) } emptyLabel.text = "暂无数据" emptyLabel.isHidden = !viewModel.rewardRows.isEmpty || viewModel.isRewardRefreshing } dataSource.apply(snapshot, animatingDifferences: animated) tableView.refreshControl?.endRefreshing() if let message = viewModel.errorMessage { showToast(message) } } @objc private func inviteTabTapped() { Task { await viewModel.selectTab(.invite, inviteAPI: inviteAPI, walletAPI: walletAPI) } } @objc private func rewardTabTapped() { Task { await viewModel.selectTab(.reward, inviteAPI: inviteAPI, walletAPI: walletAPI) } } @objc private func refreshPulled() { Task { switch viewModel.selectedTab { case .invite: await viewModel.refreshInvite(inviteAPI: inviteAPI, walletAPI: walletAPI) case .reward: await viewModel.refreshReward(walletAPI: walletAPI) } } } @objc private func walletTapped() { HomeRouteHandler.open(uri: "wallet", from: self, storeItem: nil) } private func loadMoreIfNeeded() { Task { switch viewModel.selectedTab { case .invite: await viewModel.loadMoreInvite(inviteAPI: inviteAPI) case .reward: await viewModel.loadMoreReward(walletAPI: walletAPI) } } } } extension InviteRecordViewController: UITableViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { let distanceToBottom = scrollView.contentSize.height - scrollView.bounds.height - scrollView.contentOffset.y guard distanceToBottom < 160, scrollView.contentSize.height > scrollView.bounds.height else { return } loadMoreIfNeeded() } } /// 邀请记录 Tab 按钮。 private final class InviteRecordTabButton: UIButton { override var isSelected: Bool { didSet { setTitleColor(isSelected ? AppColor.primary : UIColor(hex: 0xB6BECA), for: .normal) titleLabel?.font = .systemFont(ofSize: 16, weight: isSelected ? .medium : .regular) } } /// 初始化 Tab 按钮。 init(title: String) { super.init(frame: .zero) setTitle(title, for: .normal) setTitleColor(UIColor(hex: 0xB6BECA), for: .normal) titleLabel?.font = .systemFont(ofSize: 16) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } /// 邀请用户列表单元格。 private final class InviteUserCell: UITableViewCell { static let reuseIdentifier = "InviteUserCell" private let avatarImageView = UIImageView() private let avatarLabel = UILabel() private let nameLabel = UILabel() private let levelLabel = UILabel() private let phoneLabel = UILabel() private let timeLabel = UILabel() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none setupUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 应用邀请用户行数据。 func apply(_ row: InviteDisplayRow) { avatarLabel.text = row.avatarPlaceholder avatarImageView.loadRemoteImage(urlString: row.avatar, placeholderColor: UIColor(hex: 0xE0F2FF)) avatarLabel.isHidden = !row.avatar.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty nameLabel.text = row.title phoneLabel.text = row.phone timeLabel.text = row.time levelLabel.text = row.levelText levelLabel.textColor = row.isPrimaryLevel ? UIColor(hex: 0x22C55E) : AppColor.warning levelLabel.backgroundColor = row.isPrimaryLevel ? AppColor.successBackground : AppColor.warningBackground } private func setupUI() { let divider = UIView() divider.backgroundColor = UIColor(hex: 0xF3F4F6) contentView.addSubview(divider) avatarImageView.layer.cornerRadius = 24 avatarImageView.clipsToBounds = true avatarImageView.contentMode = .scaleAspectFill contentView.addSubview(avatarImageView) avatarLabel.font = .systemFont(ofSize: 18, weight: .medium) avatarLabel.textColor = AppColor.primary avatarLabel.textAlignment = .center avatarImageView.addSubview(avatarLabel) nameLabel.font = .systemFont(ofSize: 16, weight: .medium) nameLabel.textColor = .black levelLabel.font = .app(.caption) levelLabel.textAlignment = .center levelLabel.layer.cornerRadius = 4 levelLabel.clipsToBounds = true let nameRow = UIStackView(arrangedSubviews: [nameLabel, levelLabel]) nameRow.axis = .horizontal nameRow.alignment = .center nameRow.spacing = 4 phoneLabel.font = .app(.body) phoneLabel.textColor = UIColor(hex: 0x7B8EAA) timeLabel.font = .app(.body) timeLabel.textColor = UIColor(hex: 0xB6BECA) timeLabel.textAlignment = .right let infoRow = UIStackView(arrangedSubviews: [phoneLabel, timeLabel]) infoRow.axis = .horizontal infoRow.spacing = 8 infoRow.distribution = .fillEqually let textStack = UIStackView(arrangedSubviews: [nameRow, infoRow]) textStack.axis = .vertical textStack.spacing = 4 contentView.addSubview(textStack) divider.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.equalTo(1) } avatarImageView.snp.makeConstraints { make in make.leading.equalToSuperview() make.top.equalToSuperview().offset(13) make.size.equalTo(48) make.bottom.lessThanOrEqualToSuperview().inset(13) } avatarLabel.snp.makeConstraints { make in make.edges.equalToSuperview() } levelLabel.snp.makeConstraints { make in make.width.greaterThanOrEqualTo(40) make.height.equalTo(20) } textStack.snp.makeConstraints { make in make.leading.equalTo(avatarImageView.snp.trailing).offset(12) make.trailing.equalToSuperview() make.centerY.equalTo(avatarImageView) make.top.greaterThanOrEqualToSuperview().offset(12) make.bottom.lessThanOrEqualToSuperview().inset(12) } } } /// 奖励流水列表单元格。 private final class InviteRewardCell: UITableViewCell { static let reuseIdentifier = "InviteRewardCell" private let amountLabel = UILabel() private let timeLabel = UILabel() private let statusLabel = UILabel() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none setupUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 应用奖励流水数据。 func apply(_ item: WalletTransactionItem) { amountLabel.text = "¥ \(item.amount)" timeLabel.text = item.createdAt let label = item.withdrawLabel.isEmpty ? "--" : item.withdrawLabel statusLabel.text = label let isWithdrawable = label == "可提现" statusLabel.textColor = isWithdrawable ? UIColor(hex: 0x22C55E) : UIColor(hex: 0x7B8EAA) statusLabel.backgroundColor = isWithdrawable ? AppColor.successBackground : UIColor(hex: 0xF4F4F4) } private func setupUI() { let divider = UIView() divider.backgroundColor = UIColor(hex: 0xF3F4F6) contentView.addSubview(divider) amountLabel.font = .systemFont(ofSize: 16, weight: .medium) amountLabel.textColor = .black timeLabel.font = .app(.body) timeLabel.textColor = UIColor(hex: 0x7B8EAA) let textStack = UIStackView(arrangedSubviews: [amountLabel, timeLabel]) textStack.axis = .vertical textStack.spacing = 4 contentView.addSubview(textStack) statusLabel.font = .app(.caption) statusLabel.textAlignment = .center statusLabel.layer.cornerRadius = 4 statusLabel.clipsToBounds = true contentView.addSubview(statusLabel) divider.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.equalTo(1) } textStack.snp.makeConstraints { make in make.leading.equalToSuperview() make.top.bottom.equalToSuperview().inset(12) make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-12) } statusLabel.snp.makeConstraints { make in make.trailing.equalToSuperview() make.centerY.equalToSuperview() make.height.equalTo(22) make.width.greaterThanOrEqualTo(48) } } } /// 邀请记录列表标题与底部提示单元格。 private final class InviteFooterCell: UITableViewCell { static let reuseIdentifier = "InviteFooterCell" private let label = UILabel() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none label.textAlignment = .center contentView.addSubview(label) label.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0)) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 应用标题或底部提示文字。 func apply(text: String) { label.text = text if text == "已邀请摄影师" || text == "奖励记录" { label.textAlignment = .left label.font = .systemFont(ofSize: 16, weight: .medium) label.textColor = .black } else { label.textAlignment = .center label.font = .app(.body) label.textColor = AppColor.textSecondary } } }