feat: add WeChat sharing and invite flow

This commit is contained in:
2026-07-07 16:53:39 +08:00
parent f1937e23d4
commit a80c181bd7
160 changed files with 7557 additions and 849 deletions

View File

@ -0,0 +1,542 @@
//
// 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<Section, Item>!
///
init(
inviteAPI: InviteServing = NetworkServices.shared.inviteAPI,
walletAPI: WalletServing = NetworkServices.shared.walletAPI
) {
self.inviteAPI = inviteAPI
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)
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<Section, Item> {
UITableViewDiffableDataSource<Section, Item>(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<Section, Item>()
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
}
}
}