feat: add WeChat sharing and invite flow
This commit is contained in:
542
suixinkan/UI/Invite/InviteRecordViewController.swift
Normal file
542
suixinkan/UI/Invite/InviteRecordViewController.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
448
suixinkan/UI/Invite/PhotographerInviteViewController.swift
Normal file
448
suixinkan/UI/Invite/PhotographerInviteViewController.swift
Normal file
@ -0,0 +1,448 @@
|
||||
//
|
||||
// PhotographerInviteViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 摄影师邀请页面,展示邀请二维码、邀请码、收益规则、新人福利和分享入口。
|
||||
final class PhotographerInviteViewController: BaseViewController {
|
||||
private let viewModel = PhotographerInviteViewModel()
|
||||
private let inviteAPI: InviteServing
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack = UIStackView()
|
||||
private let qrCard = UIView()
|
||||
private let qrImageView = UIImageView()
|
||||
private let inviteCodeLabel = UILabel()
|
||||
private let ruleStack = UIStackView()
|
||||
|
||||
/// 初始化摄影师邀请页面。
|
||||
init(inviteAPI: InviteServing = NetworkServices.shared.inviteAPI) {
|
||||
self.inviteAPI = inviteAPI
|
||||
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)
|
||||
scrollView.alwaysBounceVertical = true
|
||||
scrollView.refreshControl = UIRefreshControl()
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 16
|
||||
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(contentStack)
|
||||
|
||||
contentStack.addArrangedSubview(makeInviteCard())
|
||||
contentStack.addArrangedSubview(makeRuleCard())
|
||||
contentStack.addArrangedSubview(makeBenefitCard())
|
||||
contentStack.addArrangedSubview(makeInviteRecordEntry())
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||||
}
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.top.bottom.equalTo(scrollView.contentLayoutGuide).inset(16)
|
||||
make.leading.trailing.equalTo(scrollView.frameLayoutGuide).inset(16)
|
||||
make.width.equalTo(scrollView.frameLayoutGuide).offset(-32)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
scrollView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in
|
||||
self?.applyViewModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
Task { await viewModel.reload(api: inviteAPI) }
|
||||
}
|
||||
|
||||
private func makeInviteCard() -> UIView {
|
||||
qrCard.backgroundColor = .white
|
||||
qrCard.layer.cornerRadius = 12
|
||||
qrCard.clipsToBounds = true
|
||||
|
||||
let stack = UIStackView()
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 16
|
||||
stack.alignment = .fill
|
||||
qrCard.addSubview(stack)
|
||||
|
||||
qrImageView.contentMode = .scaleAspectFit
|
||||
qrImageView.backgroundColor = .white
|
||||
qrImageView.image = UIImage(systemName: "qrcode")
|
||||
qrImageView.tintColor = AppColor.textTertiary
|
||||
stack.addArrangedSubview(qrImageView)
|
||||
|
||||
let codeRow = UIStackView()
|
||||
codeRow.axis = .horizontal
|
||||
codeRow.alignment = .center
|
||||
codeRow.spacing = 8
|
||||
|
||||
inviteCodeLabel.font = .systemFont(ofSize: 16)
|
||||
inviteCodeLabel.textColor = UIColor(hex: 0x4B5563)
|
||||
inviteCodeLabel.numberOfLines = 1
|
||||
codeRow.addArrangedSubview(inviteCodeLabel)
|
||||
|
||||
let copyButton = UIButton(type: .system)
|
||||
copyButton.setTitle("复制", for: .normal)
|
||||
copyButton.setTitleColor(.white, for: .normal)
|
||||
copyButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
copyButton.backgroundColor = AppColor.primary
|
||||
copyButton.layer.cornerRadius = 6
|
||||
copyButton.addTarget(self, action: #selector(copyCodeTapped), for: .touchUpInside)
|
||||
codeRow.addArrangedSubview(copyButton)
|
||||
copyButton.snp.makeConstraints { make in
|
||||
make.width.equalTo(64)
|
||||
make.height.equalTo(28)
|
||||
}
|
||||
stack.addArrangedSubview(codeRow)
|
||||
|
||||
let actionRow = UIStackView()
|
||||
actionRow.axis = .horizontal
|
||||
actionRow.spacing = 12
|
||||
actionRow.distribution = .fillEqually
|
||||
let shareButton = InviteIconButton(title: "分享", imageName: "ic_invite_share", backgroundColor: UIColor(hex: 0x07C160))
|
||||
shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside)
|
||||
let linkButton = InviteIconButton(title: "复制链接", imageName: "ic_invite_link", backgroundColor: AppColor.primary)
|
||||
linkButton.addTarget(self, action: #selector(copyLinkTapped), for: .touchUpInside)
|
||||
actionRow.addArrangedSubview(shareButton)
|
||||
actionRow.addArrangedSubview(linkButton)
|
||||
stack.addArrangedSubview(actionRow)
|
||||
actionRow.snp.makeConstraints { make in
|
||||
make.height.equalTo(44)
|
||||
}
|
||||
|
||||
stack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
}
|
||||
qrImageView.snp.makeConstraints { make in
|
||||
make.height.equalTo(280)
|
||||
}
|
||||
|
||||
return qrCard
|
||||
}
|
||||
|
||||
private func makeRuleCard() -> UIView {
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 12
|
||||
card.layer.borderWidth = 1
|
||||
card.layer.borderColor = AppColor.cardOutline.cgColor
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = "收益规则"
|
||||
titleLabel.font = .systemFont(ofSize: 18, weight: .medium)
|
||||
titleLabel.textColor = .black
|
||||
|
||||
ruleStack.axis = .vertical
|
||||
ruleStack.spacing = 12
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [titleLabel, ruleStack])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 12
|
||||
card.addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
private func makeBenefitCard() -> UIView {
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 12
|
||||
card.clipsToBounds = true
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = "新人福利"
|
||||
titleLabel.font = .systemFont(ofSize: 18, weight: .medium)
|
||||
titleLabel.textColor = .black
|
||||
|
||||
let grid = UIStackView()
|
||||
grid.axis = .vertical
|
||||
grid.spacing = 12
|
||||
|
||||
let benefits: [(String, String)] = [
|
||||
("免费剪辑修图", "ic_invite_benefit_free_editing"),
|
||||
("免费云盘相册", "ic_invite_benefit_free_cloud"),
|
||||
("免费线上流量", "ic_free_traffic"),
|
||||
("景区运营身份", "ic_scenic_identity"),
|
||||
("接单软件工具", "ic_order_tools"),
|
||||
("免佣金特权", "ic_no_commission"),
|
||||
]
|
||||
for index in stride(from: 0, to: benefits.count, by: 2) {
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.spacing = 12
|
||||
row.distribution = .fillEqually
|
||||
row.addArrangedSubview(InviteBenefitView(title: benefits[index].0, imageName: benefits[index].1))
|
||||
row.addArrangedSubview(InviteBenefitView(title: benefits[index + 1].0, imageName: benefits[index + 1].1))
|
||||
grid.addArrangedSubview(row)
|
||||
}
|
||||
|
||||
let validityStack = UIStackView()
|
||||
validityStack.axis = .vertical
|
||||
validityStack.spacing = 8
|
||||
["活动有效期: 2026年9月1日", "佣金有效期: 2028年1月1日"].forEach { text in
|
||||
let label = UILabel()
|
||||
label.text = text
|
||||
label.font = .app(.body)
|
||||
label.textColor = UIColor(hex: 0x7B8EAA)
|
||||
validityStack.addArrangedSubview(label)
|
||||
}
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [titleLabel, grid, validityStack])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 16
|
||||
card.addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
private func makeInviteRecordEntry() -> UIView {
|
||||
let container = UIView()
|
||||
container.backgroundColor = UIColor(hex: 0xF5F6F8)
|
||||
container.snp.makeConstraints { make in
|
||||
make.height.equalTo(64)
|
||||
}
|
||||
|
||||
let button = UIButton(type: .system)
|
||||
button.backgroundColor = .white
|
||||
button.layer.cornerRadius = 8
|
||||
button.setTitle("邀请记录", for: .normal)
|
||||
button.setTitleColor(AppColor.primary, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
button.setImage(UIImage(named: "ic_invite_record")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
button.tintColor = AppColor.primary
|
||||
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -6, bottom: 0, right: 6)
|
||||
button.addTarget(self, action: #selector(inviteRecordTapped), for: .touchUpInside)
|
||||
container.addSubview(button)
|
||||
button.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.top.equalToSuperview().offset(16)
|
||||
make.height.equalTo(48)
|
||||
}
|
||||
return container
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyViewModel() {
|
||||
if viewModel.isLoading {
|
||||
showLoading()
|
||||
} else {
|
||||
hideLoading()
|
||||
scrollView.refreshControl?.endRefreshing()
|
||||
}
|
||||
|
||||
if let qrImage = viewModel.qrImage {
|
||||
qrImageView.image = qrImage
|
||||
}
|
||||
|
||||
let attributed = NSMutableAttributedString(
|
||||
string: "邀请码: ",
|
||||
attributes: [.foregroundColor: UIColor(hex: 0x4B5563)]
|
||||
)
|
||||
attributed.append(
|
||||
NSAttributedString(
|
||||
string: viewModel.inviteCode.isEmpty ? "--" : viewModel.inviteCode,
|
||||
attributes: [.foregroundColor: AppColor.primary]
|
||||
)
|
||||
)
|
||||
inviteCodeLabel.attributedText = attributed
|
||||
applyRules()
|
||||
|
||||
if let message = viewModel.errorMessage {
|
||||
showToast("获取邀请信息失败: \(message)")
|
||||
}
|
||||
}
|
||||
|
||||
private func applyRules() {
|
||||
ruleStack.arrangedSubviews.forEach { view in
|
||||
ruleStack.removeArrangedSubview(view)
|
||||
view.removeFromSuperview()
|
||||
}
|
||||
viewModel.rules.enumerated().forEach { index, rule in
|
||||
ruleStack.addArrangedSubview(InviteRuleRow(rule: rule, imageName: index.isMultiple(of: 2) ? "ic_invite_earnings" : "ic_user_benefits"))
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func refreshPulled() {
|
||||
Task { await viewModel.reload(api: inviteAPI) }
|
||||
}
|
||||
|
||||
@objc private func copyCodeTapped() {
|
||||
guard !viewModel.inviteCodeForCopy().isEmpty else {
|
||||
showToast("邀请码加载中,请稍后重试")
|
||||
return
|
||||
}
|
||||
UIPasteboard.general.string = viewModel.inviteCodeForCopy()
|
||||
showToast("已复制成功")
|
||||
}
|
||||
|
||||
@objc private func copyLinkTapped() {
|
||||
guard !viewModel.inviteUrlForCopy().isEmpty else {
|
||||
showToast("邀请链接加载中,请稍后重试")
|
||||
return
|
||||
}
|
||||
UIPasteboard.general.string = viewModel.inviteUrlForCopy()
|
||||
showToast("已复制成功")
|
||||
}
|
||||
|
||||
@objc private func shareTapped() {
|
||||
guard !viewModel.inviteUrl.isEmpty else {
|
||||
showToast("邀请链接加载中,请稍后重试")
|
||||
return
|
||||
}
|
||||
|
||||
let payload = WeChatWebPageSharePayload(
|
||||
title: "邀请你加入随心瞰商家版",
|
||||
description: viewModel.inviteCode.isEmpty ? "摄影师邀请" : "邀请码:\(viewModel.inviteCode)",
|
||||
url: viewModel.inviteUrl
|
||||
)
|
||||
WeChatShareService.shareWebPage(payload, scene: .session, thumbImage: viewModel.qrImage ?? WeChatShareService.defaultThumbImage()) { [weak self] result in
|
||||
Task { @MainActor in
|
||||
self?.handleShareResult(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func inviteRecordTapped() {
|
||||
navigationController?.pushViewController(InviteRecordViewController(), animated: true)
|
||||
}
|
||||
|
||||
private func handleShareResult(_ result: WeChatShareResult) {
|
||||
switch result {
|
||||
case .success:
|
||||
showToast("已分享到微信好友")
|
||||
case .cancelled:
|
||||
showToast("已取消分享")
|
||||
case .notInstalled:
|
||||
showToast("未安装微信,无法分享")
|
||||
case .unsupported:
|
||||
showToast("当前微信版本不支持分享")
|
||||
case .invalidPayload(let message):
|
||||
showToast(message)
|
||||
case .sendFailed, .unknown:
|
||||
showToast("分享失败,请稍后重试")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 邀请页带图标按钮。
|
||||
private final class InviteIconButton: UIButton {
|
||||
/// 初始化带图标按钮。
|
||||
init(title: String, imageName: String, backgroundColor: UIColor) {
|
||||
super.init(frame: .zero)
|
||||
self.backgroundColor = backgroundColor
|
||||
layer.cornerRadius = 8
|
||||
setTitle(title, for: .normal)
|
||||
setTitleColor(.white, for: .normal)
|
||||
setImage(UIImage(named: imageName)?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
tintColor = .white
|
||||
titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
imageEdgeInsets = UIEdgeInsets(top: 0, left: -3, bottom: 0, right: 3)
|
||||
titleEdgeInsets = UIEdgeInsets(top: 0, left: 3, bottom: 0, right: -3)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/// 收益规则展示行。
|
||||
private final class InviteRuleRow: UIView {
|
||||
/// 初始化收益规则展示行。
|
||||
init(rule: String, imageName: String) {
|
||||
super.init(frame: .zero)
|
||||
|
||||
let iconView = UIImageView(image: UIImage(named: imageName)?.withRenderingMode(.alwaysTemplate))
|
||||
iconView.tintColor = AppColor.primary
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
|
||||
let label = UILabel()
|
||||
label.text = rule
|
||||
label.font = .app(.body)
|
||||
label.textColor = .black
|
||||
label.numberOfLines = 0
|
||||
|
||||
addSubview(iconView)
|
||||
addSubview(label)
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview()
|
||||
make.top.equalToSuperview().offset(2)
|
||||
make.size.equalTo(16)
|
||||
}
|
||||
label.snp.makeConstraints { make in
|
||||
make.top.bottom.trailing.equalToSuperview()
|
||||
make.leading.equalTo(iconView.snp.trailing).offset(12)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/// 新人福利卡片。
|
||||
private final class InviteBenefitView: UIView {
|
||||
/// 初始化新人福利卡片。
|
||||
init(title: String, imageName: String) {
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = UIColor(hex: 0xEFF6FF)
|
||||
layer.cornerRadius = 12
|
||||
|
||||
let iconView = UIImageView(image: UIImage(named: imageName)?.withRenderingMode(.alwaysTemplate))
|
||||
iconView.tintColor = AppColor.primary
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = title
|
||||
titleLabel.font = .app(.body)
|
||||
titleLabel.textColor = .black
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.numberOfLines = 2
|
||||
|
||||
addSubview(iconView)
|
||||
addSubview(titleLabel)
|
||||
snp.makeConstraints { make in
|
||||
make.height.equalTo(80)
|
||||
}
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview()
|
||||
make.top.equalToSuperview().offset(12)
|
||||
make.size.equalTo(24)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(8)
|
||||
make.top.equalTo(iconView.snp.bottom).offset(8)
|
||||
make.bottom.lessThanOrEqualToSuperview().inset(8)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user