feat: 添加获客员分成记录并重构列表
This commit is contained in:
@ -0,0 +1,94 @@
|
|||||||
|
//
|
||||||
|
// CommissionRateLogViewModel.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 获客员分成比例修改记录 ViewModel,负责刷新、分页与重复项合并。
|
||||||
|
final class CommissionRateLogViewModel {
|
||||||
|
private enum Constants {
|
||||||
|
static let pageSize = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var items: [CommissionRateLogEntity] = []
|
||||||
|
private(set) var isRefreshing = false
|
||||||
|
private(set) var isLoadingMore = false
|
||||||
|
private(set) var initialLoading = true
|
||||||
|
private(set) var canLoadMore = false
|
||||||
|
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
|
||||||
|
private let saleUserId: Int
|
||||||
|
private var total = 0
|
||||||
|
private var lastLoadedPage = 0
|
||||||
|
private var isLoading = false
|
||||||
|
|
||||||
|
init(saleUserId: Int) {
|
||||||
|
self.saleUserId = saleUserId
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重新加载第一页修改记录。
|
||||||
|
func refresh(api: OrderAPI) async {
|
||||||
|
await load(api: api, page: 1, append: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在存在下一页时加载更多修改记录。
|
||||||
|
func loadMore(api: OrderAPI) async {
|
||||||
|
guard canLoadMore, !isRefreshing, !isLoadingMore else { return }
|
||||||
|
await load(api: api, page: lastLoadedPage + 1, append: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load(api: OrderAPI, page: Int, append: Bool) async {
|
||||||
|
guard saleUserId > 0 else {
|
||||||
|
initialLoading = false
|
||||||
|
onShowMessage?("获客员信息无效")
|
||||||
|
notifyStateChange()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !isLoading else { return }
|
||||||
|
isLoading = true
|
||||||
|
if append {
|
||||||
|
isLoadingMore = true
|
||||||
|
} else {
|
||||||
|
isRefreshing = true
|
||||||
|
}
|
||||||
|
notifyStateChange()
|
||||||
|
|
||||||
|
defer {
|
||||||
|
isLoading = false
|
||||||
|
initialLoading = false
|
||||||
|
if append {
|
||||||
|
isLoadingMore = false
|
||||||
|
} else {
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await api.saleUserCommissionRateLogs(
|
||||||
|
saleUserId: saleUserId,
|
||||||
|
page: page,
|
||||||
|
pageSize: Constants.pageSize
|
||||||
|
)
|
||||||
|
if append {
|
||||||
|
let existingIDs = Set(items.map(\.id))
|
||||||
|
items.append(contentsOf: response.list.filter { !existingIDs.contains($0.id) })
|
||||||
|
} else {
|
||||||
|
var seenIDs = Set<Int>()
|
||||||
|
items = response.list.filter { seenIDs.insert($0.id).inserted }
|
||||||
|
}
|
||||||
|
total = max(0, response.total)
|
||||||
|
lastLoadedPage = page
|
||||||
|
canLoadMore = items.count < total && !response.list.isEmpty
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyStateChange() {
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -293,6 +293,28 @@ final class OrderAPI {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取指定获客员的分成比例修改记录。
|
||||||
|
func saleUserCommissionRateLogs(
|
||||||
|
saleUserId: Int,
|
||||||
|
page: Int = 1,
|
||||||
|
pageSize: Int = 10
|
||||||
|
) async throws -> CommissionRateLogListResponse {
|
||||||
|
guard saleUserId > 0 else {
|
||||||
|
throw APIError.networkFailed("获客员信息无效")
|
||||||
|
}
|
||||||
|
return try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "/api/yf-handset-app/photog/sale-user/commission-rate/logs",
|
||||||
|
queryItems: [
|
||||||
|
URLQueryItem(name: "sale_user_id", value: String(saleUserId)),
|
||||||
|
URLQueryItem(name: "page", value: String(max(1, page))),
|
||||||
|
URLQueryItem(name: "page_size", value: String(min(max(1, pageSize), 50))),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func shootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
|
func shootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
|
||||||
try await client.send(
|
try await client.send(
|
||||||
APIRequest(
|
APIRequest(
|
||||||
|
|||||||
@ -875,6 +875,93 @@ struct CooperativeSalerEntity: Decodable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获客员分成比例修改记录分页响应。
|
||||||
|
struct CommissionRateLogListResponse: Decodable, Equatable {
|
||||||
|
let list: [CommissionRateLogEntity]
|
||||||
|
let total: Int
|
||||||
|
let page: Int
|
||||||
|
let pageSize: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case list
|
||||||
|
case total
|
||||||
|
case page
|
||||||
|
case pageSize = "page_size"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单条获客员分成比例修改记录。
|
||||||
|
struct CommissionRateLogEntity: Decodable, Equatable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let bindingId: Int
|
||||||
|
let saleUserId: Int
|
||||||
|
let storeUserId: Int
|
||||||
|
let beforeRate: Int
|
||||||
|
let beforeRateLabel: String
|
||||||
|
let afterRate: Int
|
||||||
|
let afterRateLabel: String
|
||||||
|
let operatorType: String
|
||||||
|
let operatorId: Int
|
||||||
|
let operatorName: String
|
||||||
|
let remark: String
|
||||||
|
let createdAt: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case bindingId = "binding_id"
|
||||||
|
case saleUserId = "sale_user_id"
|
||||||
|
case storeUserId = "store_user_id"
|
||||||
|
case beforeRate = "before_rate"
|
||||||
|
case beforeRateLabel = "before_rate_label"
|
||||||
|
case afterRate = "after_rate"
|
||||||
|
case afterRateLabel = "after_rate_label"
|
||||||
|
case operatorType = "operator_type"
|
||||||
|
case operatorId = "operator_id"
|
||||||
|
case operatorName = "operator_name"
|
||||||
|
case remark
|
||||||
|
case createdAt = "created_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 修改前比例展示文本,优先使用服务端 label。
|
||||||
|
var displayBeforeRate: String {
|
||||||
|
let label = beforeRateLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return label.isEmpty ? "\(beforeRate)%" : label
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 修改后比例展示文本,优先使用服务端 label。
|
||||||
|
var displayAfterRate: String {
|
||||||
|
let label = afterRateLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return label.isEmpty ? "\(afterRate)%" : label
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 修改时间展示文本。
|
||||||
|
var metadataLine: String {
|
||||||
|
formattedCreatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 去除首尾空白后的备注;空备注不展示。
|
||||||
|
var displayRemark: String? {
|
||||||
|
let value = remark.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return value.isEmpty ? nil : value
|
||||||
|
}
|
||||||
|
|
||||||
|
private var formattedCreatedAt: String {
|
||||||
|
let value = createdAt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !value.isEmpty else { return "—" }
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
formatter.timeZone = TimeZone(identifier: "Asia/Shanghai")
|
||||||
|
for format in ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm"] {
|
||||||
|
formatter.dateFormat = format
|
||||||
|
if let date = formatter.date(from: value) {
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ReferralOrderListResponse: Decodable {
|
struct ReferralOrderListResponse: Decodable {
|
||||||
let total: Int
|
let total: Int
|
||||||
let list: [ReferralOrderEntity]
|
let list: [ReferralOrderEntity]
|
||||||
|
|||||||
@ -0,0 +1,284 @@
|
|||||||
|
//
|
||||||
|
// CommissionRateLogViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 获客员分成比例修改记录页,以时间轴形式展示历史变更。
|
||||||
|
final class CommissionRateLogViewController: BaseViewController {
|
||||||
|
private let viewModel: CommissionRateLogViewModel
|
||||||
|
private let orderAPI: OrderAPI
|
||||||
|
private let navigationTitle: String
|
||||||
|
|
||||||
|
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||||
|
private let emptyLabel = UILabel()
|
||||||
|
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
|
private let footerLabel = UILabel()
|
||||||
|
private var dataSource: UITableViewDiffableDataSource<Int, CommissionRateLogEntity>!
|
||||||
|
|
||||||
|
init(saleUserId: Int, acquirerName: String, orderAPI: OrderAPI? = nil) {
|
||||||
|
viewModel = CommissionRateLogViewModel(saleUserId: saleUserId)
|
||||||
|
let trimmedName = acquirerName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
navigationTitle = trimmedName.isEmpty ? "修改记录" : "给\(trimmedName)的分成记录"
|
||||||
|
self.orderAPI = orderAPI ?? NetworkServices.shared.orderAPI
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
Task { await viewModel.refresh(api: orderAPI) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
title = navigationTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
view.backgroundColor = AppColor.pageBackground
|
||||||
|
|
||||||
|
tableView.backgroundColor = AppColor.pageBackground
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
|
tableView.estimatedRowHeight = 132
|
||||||
|
tableView.delegate = self
|
||||||
|
tableView.contentInset.top = AppSpacing.sm
|
||||||
|
tableView.register(
|
||||||
|
CommissionRateLogTimelineCell.self,
|
||||||
|
forCellReuseIdentifier: CommissionRateLogTimelineCell.reuseIdentifier
|
||||||
|
)
|
||||||
|
tableView.refreshControl = UIRefreshControl()
|
||||||
|
tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||||
|
|
||||||
|
dataSource = UITableViewDiffableDataSource<Int, CommissionRateLogEntity>(
|
||||||
|
tableView: tableView
|
||||||
|
) { tableView, indexPath, item in
|
||||||
|
guard let cell = tableView.dequeueReusableCell(
|
||||||
|
withIdentifier: CommissionRateLogTimelineCell.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as? CommissionRateLogTimelineCell else {
|
||||||
|
return UITableViewCell()
|
||||||
|
}
|
||||||
|
cell.configure(with: item, isLatest: indexPath.item == 0)
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyLabel.text = "暂无修改记录"
|
||||||
|
emptyLabel.font = .systemFont(ofSize: 14)
|
||||||
|
emptyLabel.textColor = AppColor.textTertiary
|
||||||
|
emptyLabel.textAlignment = .center
|
||||||
|
emptyLabel.isHidden = true
|
||||||
|
|
||||||
|
loadingIndicator.color = AppColor.primary
|
||||||
|
|
||||||
|
footerLabel.font = .systemFont(ofSize: 13)
|
||||||
|
footerLabel.textColor = AppColor.textTertiary
|
||||||
|
footerLabel.textAlignment = .center
|
||||||
|
|
||||||
|
view.addSubview(tableView)
|
||||||
|
view.addSubview(emptyLabel)
|
||||||
|
view.addSubview(loadingIndicator)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
tableView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
}
|
||||||
|
emptyLabel.snp.makeConstraints { make in
|
||||||
|
make.center.equalTo(tableView)
|
||||||
|
}
|
||||||
|
loadingIndicator.snp.makeConstraints { make in
|
||||||
|
make.center.equalTo(tableView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func bindActions() {
|
||||||
|
viewModel.onStateChange = { [weak self] in
|
||||||
|
Task { @MainActor in self?.applyState() }
|
||||||
|
}
|
||||||
|
viewModel.onShowMessage = { [weak self] message in
|
||||||
|
Task { @MainActor in self?.showToast(message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyState() {
|
||||||
|
if !viewModel.isRefreshing {
|
||||||
|
tableView.refreshControl?.endRefreshing()
|
||||||
|
}
|
||||||
|
if viewModel.initialLoading && viewModel.items.isEmpty {
|
||||||
|
loadingIndicator.startAnimating()
|
||||||
|
} else {
|
||||||
|
loadingIndicator.stopAnimating()
|
||||||
|
}
|
||||||
|
emptyLabel.isHidden = viewModel.initialLoading || viewModel.isRefreshing || !viewModel.items.isEmpty
|
||||||
|
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Int, CommissionRateLogEntity>()
|
||||||
|
snapshot.appendSections([0])
|
||||||
|
snapshot.appendItems(viewModel.items)
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
|
||||||
|
guard !viewModel.items.isEmpty else {
|
||||||
|
tableView.tableFooterView = UIView()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
footerLabel.text = viewModel.isLoadingMore ? "加载中…" : (viewModel.canLoadMore ? "" : "没有更多")
|
||||||
|
footerLabel.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52)
|
||||||
|
tableView.tableFooterView = footerLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func refreshPulled() {
|
||||||
|
Task { await viewModel.refresh(api: orderAPI) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CommissionRateLogViewController: UITableViewDelegate {
|
||||||
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
guard !viewModel.items.isEmpty,
|
||||||
|
scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 180 else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task { await viewModel.loadMore(api: orderAPI) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 分成比例修改记录的时间轴 Cell。
|
||||||
|
final class CommissionRateLogTimelineCell: UITableViewCell {
|
||||||
|
static let reuseIdentifier = "CommissionRateLogTimelineCell"
|
||||||
|
|
||||||
|
private let railView = UIView()
|
||||||
|
private let dotView = UIView()
|
||||||
|
private let cardView = UIView()
|
||||||
|
private let metadataLabel = UILabel()
|
||||||
|
private let latestBadgeLabel = UILabel()
|
||||||
|
private let beforeRateLabel = UILabel()
|
||||||
|
private let arrowImageView = UIImageView(image: UIImage(systemName: "arrow.right"))
|
||||||
|
private let afterRateLabel = UILabel()
|
||||||
|
private let remarkLabel = UILabel()
|
||||||
|
private let contentStack = UIStackView()
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
selectionStyle = .none
|
||||||
|
backgroundColor = AppColor.pageBackground
|
||||||
|
contentView.backgroundColor = AppColor.pageBackground
|
||||||
|
|
||||||
|
railView.backgroundColor = AppColor.primary.withAlphaComponent(0.16)
|
||||||
|
dotView.backgroundColor = AppColor.primary
|
||||||
|
dotView.layer.cornerRadius = 4
|
||||||
|
|
||||||
|
cardView.backgroundColor = .white
|
||||||
|
cardView.layer.cornerRadius = AppRadius.md
|
||||||
|
cardView.layer.shadowColor = UIColor.black.cgColor
|
||||||
|
cardView.layer.shadowOpacity = 0.04
|
||||||
|
cardView.layer.shadowOffset = CGSize(width: 0, height: 1)
|
||||||
|
cardView.layer.shadowRadius = 2
|
||||||
|
|
||||||
|
metadataLabel.font = .systemFont(ofSize: 12)
|
||||||
|
metadataLabel.textColor = AppColor.textSecondary
|
||||||
|
metadataLabel.numberOfLines = 2
|
||||||
|
|
||||||
|
latestBadgeLabel.text = "最新"
|
||||||
|
latestBadgeLabel.font = .systemFont(ofSize: 11, weight: .semibold)
|
||||||
|
latestBadgeLabel.textColor = AppColor.primary
|
||||||
|
latestBadgeLabel.textAlignment = .center
|
||||||
|
latestBadgeLabel.backgroundColor = AppColor.primary.withAlphaComponent(0.10)
|
||||||
|
latestBadgeLabel.layer.cornerRadius = 9
|
||||||
|
latestBadgeLabel.clipsToBounds = true
|
||||||
|
latestBadgeLabel.isHidden = true
|
||||||
|
latestBadgeLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
latestBadgeLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
latestBadgeLabel.snp.makeConstraints { make in
|
||||||
|
make.width.equalTo(38)
|
||||||
|
make.height.equalTo(18)
|
||||||
|
}
|
||||||
|
|
||||||
|
let headerStack = UIStackView(arrangedSubviews: [metadataLabel, latestBadgeLabel])
|
||||||
|
headerStack.axis = .horizontal
|
||||||
|
headerStack.alignment = .top
|
||||||
|
headerStack.spacing = AppSpacing.sm
|
||||||
|
|
||||||
|
[beforeRateLabel, afterRateLabel].forEach { label in
|
||||||
|
label.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
label.textColor = AppColor.textPrimary
|
||||||
|
label.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
}
|
||||||
|
afterRateLabel.textColor = AppColor.primary
|
||||||
|
arrowImageView.tintColor = AppColor.textTertiary
|
||||||
|
arrowImageView.contentMode = .scaleAspectFit
|
||||||
|
|
||||||
|
let rateStack = UIStackView(arrangedSubviews: [beforeRateLabel, arrowImageView, afterRateLabel, UIView()])
|
||||||
|
rateStack.axis = .horizontal
|
||||||
|
rateStack.alignment = .center
|
||||||
|
rateStack.spacing = AppSpacing.sm
|
||||||
|
arrowImageView.snp.makeConstraints { make in
|
||||||
|
make.size.equalTo(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
remarkLabel.font = .systemFont(ofSize: 13)
|
||||||
|
remarkLabel.textColor = AppColor.textSecondary
|
||||||
|
remarkLabel.numberOfLines = 0
|
||||||
|
|
||||||
|
contentStack.axis = .vertical
|
||||||
|
contentStack.spacing = AppSpacing.sm
|
||||||
|
contentStack.addArrangedSubview(headerStack)
|
||||||
|
contentStack.addArrangedSubview(rateStack)
|
||||||
|
contentStack.addArrangedSubview(remarkLabel)
|
||||||
|
|
||||||
|
contentView.addSubview(railView)
|
||||||
|
contentView.addSubview(dotView)
|
||||||
|
contentView.addSubview(cardView)
|
||||||
|
cardView.addSubview(contentStack)
|
||||||
|
|
||||||
|
railView.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(24)
|
||||||
|
make.top.bottom.equalToSuperview()
|
||||||
|
make.width.equalTo(1.5)
|
||||||
|
}
|
||||||
|
dotView.snp.makeConstraints { make in
|
||||||
|
make.centerX.equalTo(railView)
|
||||||
|
make.top.equalToSuperview().offset(20)
|
||||||
|
make.size.equalTo(8)
|
||||||
|
}
|
||||||
|
cardView.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(railView.snp.trailing).offset(16)
|
||||||
|
make.trailing.equalToSuperview().inset(14)
|
||||||
|
make.top.equalToSuperview().offset(4)
|
||||||
|
make.bottom.equalToSuperview().inset(8)
|
||||||
|
}
|
||||||
|
contentStack.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(15)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 绑定比例变化、时间及可选备注。
|
||||||
|
func configure(with item: CommissionRateLogEntity, isLatest: Bool) {
|
||||||
|
metadataLabel.text = item.metadataLine
|
||||||
|
latestBadgeLabel.isHidden = !isLatest
|
||||||
|
beforeRateLabel.text = item.displayBeforeRate
|
||||||
|
afterRateLabel.text = item.displayAfterRate
|
||||||
|
if let remark = item.displayRemark {
|
||||||
|
remarkLabel.text = "备注:\(remark)"
|
||||||
|
remarkLabel.isHidden = false
|
||||||
|
} else {
|
||||||
|
remarkLabel.text = nil
|
||||||
|
remarkLabel.isHidden = true
|
||||||
|
}
|
||||||
|
accessibilityLabel = [
|
||||||
|
isLatest ? "最新记录" : nil,
|
||||||
|
item.metadataLine,
|
||||||
|
"\(item.displayBeforeRate)修改为\(item.displayAfterRate)",
|
||||||
|
item.displayRemark.map { "备注\($0)" },
|
||||||
|
].compactMap { $0 }.joined(separator: ",")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -31,12 +31,13 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func setupUI() {
|
override func setupUI() {
|
||||||
|
view.backgroundColor = UIColor(hex: 0xF6F7FA)
|
||||||
tableView.backgroundColor = .clear
|
tableView.backgroundColor = .clear
|
||||||
tableView.separatorStyle = .none
|
tableView.separatorStyle = .none
|
||||||
tableView.rowHeight = UITableView.automaticDimension
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
tableView.estimatedRowHeight = 142
|
tableView.estimatedRowHeight = 224
|
||||||
tableView.contentInset.top = AppSpacing.sm
|
tableView.contentInset = UIEdgeInsets(top: AppSpacing.sm, left: 0, bottom: AppSpacing.md, right: 0)
|
||||||
tableView.verticalScrollIndicatorInsets.top = AppSpacing.sm
|
tableView.verticalScrollIndicatorInsets = UIEdgeInsets(top: AppSpacing.sm, left: 0, bottom: AppSpacing.md, right: 0)
|
||||||
tableView.dataSource = self
|
tableView.dataSource = self
|
||||||
tableView.delegate = self
|
tableView.delegate = self
|
||||||
tableView.register(CooperationAcquirerCell.self, forCellReuseIdentifier: CooperationAcquirerCell.reuseIdentifier)
|
tableView.register(CooperationAcquirerCell.self, forCellReuseIdentifier: CooperationAcquirerCell.reuseIdentifier)
|
||||||
@ -245,6 +246,17 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa
|
|||||||
cell.onEditCommission = { [weak self] in
|
cell.onEditCommission = { [weak self] in
|
||||||
self?.presentCommissionDialog(for: acquirer)
|
self?.presentCommissionDialog(for: acquirer)
|
||||||
}
|
}
|
||||||
|
cell.onViewCommissionLogs = { [weak self] in
|
||||||
|
guard acquirer.saleUserId > 0 else {
|
||||||
|
self?.showToast("获客员信息无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let controller = CommissionRateLogViewController(
|
||||||
|
saleUserId: acquirer.saleUserId,
|
||||||
|
acquirerName: acquirer.displayName
|
||||||
|
)
|
||||||
|
self?.navigationController?.pushViewController(controller, animated: true)
|
||||||
|
}
|
||||||
cell.onCall = {
|
cell.onCall = {
|
||||||
let phone = acquirer.displayPhone.trimmingCharacters(in: .whitespacesAndNewlines)
|
let phone = acquirer.displayPhone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !phone.isEmpty, let url = URL(string: "tel://\(phone)") else { return }
|
guard !phone.isEmpty, let url = URL(string: "tel://\(phone)") else { return }
|
||||||
|
|||||||
@ -445,22 +445,25 @@ final class CooperationAcquirerCell: UITableViewCell {
|
|||||||
|
|
||||||
var onEditRemark: (() -> Void)?
|
var onEditRemark: (() -> Void)?
|
||||||
var onEditCommission: (() -> Void)?
|
var onEditCommission: (() -> Void)?
|
||||||
|
var onViewCommissionLogs: (() -> Void)?
|
||||||
var onCall: (() -> Void)?
|
var onCall: (() -> Void)?
|
||||||
|
|
||||||
private let cardView = UIView()
|
private let cardView = UIView()
|
||||||
private let avatarContainer = UIView()
|
private let avatarContainer = UIView()
|
||||||
private let avatarIconView = UIImageView(image: UIImage(systemName: "person.fill"))
|
private let avatarIconView = UIImageView(image: UIImage(systemName: "person.fill"))
|
||||||
private let nameTitleLabel = UILabel()
|
|
||||||
private let nameValueLabel = UILabel()
|
private let nameValueLabel = UILabel()
|
||||||
private let editRemarkButton = UIButton(type: .system)
|
|
||||||
private let phoneTitleLabel = UILabel()
|
|
||||||
private let phoneValueLabel = UILabel()
|
private let phoneValueLabel = UILabel()
|
||||||
private let callButton = UIButton(type: .system)
|
private let callButton = UIButton(type: .system)
|
||||||
private let bindTimeTitleLabel = UILabel()
|
private let commissionContainer = UIView()
|
||||||
private let bindTimeValueLabel = UILabel()
|
|
||||||
private let commissionTitleLabel = UILabel()
|
private let commissionTitleLabel = UILabel()
|
||||||
private let commissionValueLabel = UILabel()
|
private let commissionValueLabel = UILabel()
|
||||||
|
private let bindTimeIconView = UIImageView(image: UIImage(systemName: "calendar"))
|
||||||
|
private let bindTimeValueLabel = UILabel()
|
||||||
|
private let dividerView = UIView()
|
||||||
|
private let editRemarkButton = UIButton(type: .system)
|
||||||
private let editCommissionButton = UIButton(type: .system)
|
private let editCommissionButton = UIButton(type: .system)
|
||||||
|
private let commissionLogButton = UIButton(type: .system)
|
||||||
|
private let actionStack = UIStackView()
|
||||||
|
|
||||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
@ -469,135 +472,180 @@ final class CooperationAcquirerCell: UITableViewCell {
|
|||||||
contentView.backgroundColor = .clear
|
contentView.backgroundColor = .clear
|
||||||
|
|
||||||
cardView.backgroundColor = .white
|
cardView.backgroundColor = .white
|
||||||
cardView.layer.cornerRadius = AppRadius.md
|
cardView.layer.cornerRadius = AppRadius.xl
|
||||||
|
cardView.layer.borderWidth = 0.5
|
||||||
|
cardView.layer.borderColor = AppColor.cardOutline.cgColor
|
||||||
cardView.layer.shadowColor = UIColor.black.cgColor
|
cardView.layer.shadowColor = UIColor.black.cgColor
|
||||||
cardView.layer.shadowOpacity = 0.04
|
cardView.layer.shadowOpacity = 0.04
|
||||||
cardView.layer.shadowOffset = CGSize(width: 0, height: 1)
|
cardView.layer.shadowOffset = CGSize(width: 0, height: 3)
|
||||||
cardView.layer.shadowRadius = 2
|
cardView.layer.shadowRadius = 8
|
||||||
|
cardView.accessibilityIdentifier = "cooperation_acquirer_card"
|
||||||
|
|
||||||
avatarContainer.backgroundColor = AppColor.primary.withAlphaComponent(0.08)
|
avatarContainer.backgroundColor = AppColor.primary.withAlphaComponent(0.08)
|
||||||
avatarContainer.layer.cornerRadius = AppRadius.sm
|
avatarContainer.layer.cornerRadius = 24
|
||||||
avatarContainer.clipsToBounds = true
|
avatarContainer.clipsToBounds = true
|
||||||
|
|
||||||
avatarIconView.tintColor = AppColor.primary
|
avatarIconView.tintColor = AppColor.primary
|
||||||
avatarIconView.contentMode = .scaleAspectFit
|
avatarIconView.contentMode = .scaleAspectFit
|
||||||
|
|
||||||
configureTitleLabel(nameTitleLabel, text: "获客员名称")
|
nameValueLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
configureTitleLabel(phoneTitleLabel, text: "手机号")
|
|
||||||
configureTitleLabel(bindTimeTitleLabel, text: "绑定时间")
|
|
||||||
configureTitleLabel(commissionTitleLabel, text: "分成比例")
|
|
||||||
|
|
||||||
nameValueLabel.font = .systemFont(ofSize: 15, weight: .bold)
|
|
||||||
nameValueLabel.textColor = AppColor.textPrimary
|
nameValueLabel.textColor = AppColor.textPrimary
|
||||||
nameValueLabel.numberOfLines = 1
|
nameValueLabel.numberOfLines = 1
|
||||||
nameValueLabel.lineBreakMode = .byTruncatingTail
|
nameValueLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
nameValueLabel.accessibilityIdentifier = "cooperation_acquirer_name"
|
||||||
|
|
||||||
[phoneValueLabel, bindTimeValueLabel, commissionValueLabel].forEach { label in
|
phoneValueLabel.font = .systemFont(ofSize: 14)
|
||||||
label.font = .systemFont(ofSize: 13)
|
phoneValueLabel.textColor = AppColor.textTertiary
|
||||||
label.textColor = AppColor.textSecondary
|
phoneValueLabel.numberOfLines = 1
|
||||||
label.numberOfLines = 1
|
phoneValueLabel.lineBreakMode = .byTruncatingTail
|
||||||
label.lineBreakMode = .byTruncatingTail
|
phoneValueLabel.accessibilityIdentifier = "cooperation_acquirer_phone"
|
||||||
}
|
|
||||||
commissionValueLabel.font = .systemFont(ofSize: 13, weight: .medium)
|
|
||||||
|
|
||||||
editRemarkButton.setTitle("修改备注", for: .normal)
|
|
||||||
editRemarkButton.titleLabel?.font = .systemFont(ofSize: 13)
|
|
||||||
editRemarkButton.setTitleColor(AppColor.primary, for: .normal)
|
|
||||||
editRemarkButton.setConfigurationContentInsets(
|
|
||||||
NSDirectionalEdgeInsets(top: 16, leading: 14, bottom: 16, trailing: 14)
|
|
||||||
)
|
|
||||||
editRemarkButton.addTarget(self, action: #selector(editRemarkTapped), for: .touchUpInside)
|
|
||||||
|
|
||||||
callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal)
|
callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal)
|
||||||
callButton.tintColor = AppColor.primary
|
callButton.tintColor = AppColor.primary
|
||||||
callButton.backgroundColor = AppColor.primaryLight
|
callButton.backgroundColor = AppColor.primaryLight
|
||||||
callButton.layer.cornerRadius = 15
|
callButton.layer.cornerRadius = AppSpacing.minTouchTarget / 2
|
||||||
|
callButton.layer.borderWidth = 0.5
|
||||||
|
callButton.layer.borderColor = AppColor.primary.withAlphaComponent(0.12).cgColor
|
||||||
callButton.imageView?.contentMode = .scaleAspectFit
|
callButton.imageView?.contentMode = .scaleAspectFit
|
||||||
|
callButton.accessibilityLabel = "拨打获客员电话"
|
||||||
|
callButton.accessibilityIdentifier = "cooperation_acquirer_call"
|
||||||
callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside)
|
callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside)
|
||||||
|
|
||||||
editCommissionButton.setTitle("修改比例", for: .normal)
|
commissionContainer.backgroundColor = AppColor.primary.withAlphaComponent(0.06)
|
||||||
editCommissionButton.titleLabel?.font = .systemFont(ofSize: 13)
|
commissionContainer.layer.cornerRadius = AppRadius.lg
|
||||||
editCommissionButton.setTitleColor(AppColor.primary, for: .normal)
|
|
||||||
editCommissionButton.setConfigurationContentInsets(
|
commissionTitleLabel.text = "分成比例"
|
||||||
NSDirectionalEdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)
|
commissionTitleLabel.font = .systemFont(ofSize: 12)
|
||||||
|
commissionTitleLabel.textColor = AppColor.textTertiary
|
||||||
|
|
||||||
|
commissionValueLabel.font = .systemFont(ofSize: 24, weight: .bold)
|
||||||
|
commissionValueLabel.textColor = AppColor.primary
|
||||||
|
commissionValueLabel.numberOfLines = 1
|
||||||
|
commissionValueLabel.adjustsFontSizeToFitWidth = true
|
||||||
|
commissionValueLabel.minimumScaleFactor = 0.75
|
||||||
|
commissionValueLabel.accessibilityIdentifier = "cooperation_acquirer_commission_rate"
|
||||||
|
|
||||||
|
bindTimeIconView.tintColor = AppColor.textTertiary
|
||||||
|
bindTimeIconView.contentMode = .scaleAspectFit
|
||||||
|
|
||||||
|
bindTimeValueLabel.font = .systemFont(ofSize: 13)
|
||||||
|
bindTimeValueLabel.textColor = AppColor.textTertiary
|
||||||
|
bindTimeValueLabel.numberOfLines = 2
|
||||||
|
bindTimeValueLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
bindTimeValueLabel.accessibilityIdentifier = "cooperation_acquirer_bind_time"
|
||||||
|
|
||||||
|
dividerView.backgroundColor = AppColor.cardOutline
|
||||||
|
|
||||||
|
configureActionButton(
|
||||||
|
editRemarkButton,
|
||||||
|
title: "修改备注",
|
||||||
|
isPrimary: false,
|
||||||
|
accessibilityIdentifier: "cooperation_acquirer_edit_remark"
|
||||||
|
)
|
||||||
|
editRemarkButton.addTarget(self, action: #selector(editRemarkTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
configureActionButton(
|
||||||
|
commissionLogButton,
|
||||||
|
title: "修改记录",
|
||||||
|
isPrimary: false,
|
||||||
|
accessibilityIdentifier: "cooperation_acquirer_commission_logs"
|
||||||
|
)
|
||||||
|
commissionLogButton.accessibilityLabel = "查看分成比例修改记录"
|
||||||
|
commissionLogButton.addTarget(self, action: #selector(commissionLogTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
configureActionButton(
|
||||||
|
editCommissionButton,
|
||||||
|
title: "修改比例",
|
||||||
|
isPrimary: true,
|
||||||
|
accessibilityIdentifier: "cooperation_acquirer_edit_commission"
|
||||||
)
|
)
|
||||||
editCommissionButton.addTarget(self, action: #selector(editCommissionTapped), for: .touchUpInside)
|
editCommissionButton.addTarget(self, action: #selector(editCommissionTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
actionStack.axis = .horizontal
|
||||||
|
actionStack.alignment = .fill
|
||||||
|
actionStack.distribution = .fillEqually
|
||||||
|
actionStack.spacing = AppSpacing.xs
|
||||||
|
actionStack.addArrangedSubview(editRemarkButton)
|
||||||
|
actionStack.addArrangedSubview(commissionLogButton)
|
||||||
|
actionStack.addArrangedSubview(editCommissionButton)
|
||||||
|
|
||||||
contentView.addSubview(cardView)
|
contentView.addSubview(cardView)
|
||||||
cardView.addSubview(avatarContainer)
|
cardView.addSubview(avatarContainer)
|
||||||
avatarContainer.addSubview(avatarIconView)
|
avatarContainer.addSubview(avatarIconView)
|
||||||
cardView.addSubview(nameTitleLabel)
|
|
||||||
cardView.addSubview(nameValueLabel)
|
cardView.addSubview(nameValueLabel)
|
||||||
cardView.addSubview(editRemarkButton)
|
|
||||||
cardView.addSubview(phoneTitleLabel)
|
|
||||||
cardView.addSubview(phoneValueLabel)
|
cardView.addSubview(phoneValueLabel)
|
||||||
cardView.addSubview(callButton)
|
cardView.addSubview(callButton)
|
||||||
cardView.addSubview(bindTimeTitleLabel)
|
cardView.addSubview(commissionContainer)
|
||||||
|
commissionContainer.addSubview(commissionTitleLabel)
|
||||||
|
commissionContainer.addSubview(commissionValueLabel)
|
||||||
|
cardView.addSubview(bindTimeIconView)
|
||||||
cardView.addSubview(bindTimeValueLabel)
|
cardView.addSubview(bindTimeValueLabel)
|
||||||
cardView.addSubview(commissionTitleLabel)
|
cardView.addSubview(dividerView)
|
||||||
cardView.addSubview(commissionValueLabel)
|
cardView.addSubview(actionStack)
|
||||||
cardView.addSubview(editCommissionButton)
|
|
||||||
|
|
||||||
cardView.snp.makeConstraints { make in
|
cardView.snp.makeConstraints { make in
|
||||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 14, bottom: AppSpacing.sm, right: 14))
|
make.edges.equalToSuperview().inset(
|
||||||
|
UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: AppSpacing.sm, right: AppSpacing.md)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
avatarContainer.snp.makeConstraints { make in
|
avatarContainer.snp.makeConstraints { make in
|
||||||
make.leading.equalToSuperview().offset(14)
|
make.top.leading.equalToSuperview().inset(AppSpacing.md)
|
||||||
make.centerY.equalToSuperview()
|
make.size.equalTo(48)
|
||||||
make.size.equalTo(44)
|
|
||||||
}
|
}
|
||||||
avatarIconView.snp.makeConstraints { make in
|
avatarIconView.snp.makeConstraints { make in
|
||||||
make.center.equalToSuperview()
|
make.center.equalToSuperview()
|
||||||
make.size.equalTo(22)
|
make.size.equalTo(24)
|
||||||
}
|
|
||||||
nameTitleLabel.snp.makeConstraints { make in
|
|
||||||
make.top.equalToSuperview().offset(AppSpacing.md)
|
|
||||||
make.leading.equalTo(avatarContainer.snp.trailing).offset(AppSpacing.sm)
|
|
||||||
}
|
}
|
||||||
nameValueLabel.snp.makeConstraints { make in
|
nameValueLabel.snp.makeConstraints { make in
|
||||||
make.centerY.equalTo(nameTitleLabel)
|
make.top.equalTo(avatarContainer).offset(2)
|
||||||
make.leading.equalTo(nameTitleLabel.snp.trailing).offset(AppSpacing.sm)
|
make.leading.equalTo(avatarContainer.snp.trailing).offset(AppSpacing.sm)
|
||||||
make.trailing.lessThanOrEqualTo(editRemarkButton.snp.leading).offset(-AppSpacing.xs)
|
make.trailing.lessThanOrEqualTo(callButton.snp.leading).offset(-AppSpacing.sm)
|
||||||
}
|
|
||||||
editRemarkButton.snp.makeConstraints { make in
|
|
||||||
make.top.trailing.equalToSuperview()
|
|
||||||
}
|
|
||||||
phoneTitleLabel.snp.makeConstraints { make in
|
|
||||||
make.top.equalTo(nameTitleLabel.snp.bottom).offset(AppSpacing.xs)
|
|
||||||
make.leading.equalTo(nameTitleLabel)
|
|
||||||
}
|
}
|
||||||
phoneValueLabel.snp.makeConstraints { make in
|
phoneValueLabel.snp.makeConstraints { make in
|
||||||
make.centerY.equalTo(phoneTitleLabel)
|
make.top.equalTo(nameValueLabel.snp.bottom).offset(AppSpacing.xxs)
|
||||||
make.leading.equalTo(phoneTitleLabel.snp.trailing).offset(AppSpacing.sm)
|
make.leading.equalTo(nameValueLabel)
|
||||||
make.trailing.lessThanOrEqualTo(callButton.snp.leading).offset(-AppSpacing.xs)
|
make.trailing.lessThanOrEqualTo(callButton.snp.leading).offset(-AppSpacing.sm)
|
||||||
}
|
}
|
||||||
callButton.snp.makeConstraints { make in
|
callButton.snp.makeConstraints { make in
|
||||||
make.trailing.equalToSuperview().inset(14)
|
make.top.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||||
make.centerY.equalTo(phoneTitleLabel)
|
make.size.equalTo(AppSpacing.minTouchTarget)
|
||||||
make.size.equalTo(30)
|
|
||||||
}
|
}
|
||||||
bindTimeTitleLabel.snp.makeConstraints { make in
|
commissionContainer.snp.makeConstraints { make in
|
||||||
make.top.equalTo(phoneTitleLabel.snp.bottom).offset(AppSpacing.xs)
|
make.top.equalTo(avatarContainer.snp.bottom).offset(14)
|
||||||
make.leading.equalTo(nameTitleLabel)
|
make.leading.equalToSuperview().inset(AppSpacing.md)
|
||||||
}
|
make.width.equalTo(120)
|
||||||
bindTimeValueLabel.snp.makeConstraints { make in
|
make.height.equalTo(64)
|
||||||
make.centerY.equalTo(bindTimeTitleLabel)
|
|
||||||
make.leading.equalTo(bindTimeTitleLabel.snp.trailing).offset(AppSpacing.sm)
|
|
||||||
make.trailing.lessThanOrEqualToSuperview().inset(14)
|
|
||||||
}
|
}
|
||||||
commissionTitleLabel.snp.makeConstraints { make in
|
commissionTitleLabel.snp.makeConstraints { make in
|
||||||
make.top.equalTo(bindTimeTitleLabel.snp.bottom).offset(AppSpacing.xs)
|
make.top.equalToSuperview().offset(AppSpacing.xs)
|
||||||
make.leading.equalTo(nameTitleLabel)
|
make.centerX.equalToSuperview()
|
||||||
make.bottom.equalToSuperview().inset(AppSpacing.md)
|
|
||||||
}
|
}
|
||||||
commissionValueLabel.snp.makeConstraints { make in
|
commissionValueLabel.snp.makeConstraints { make in
|
||||||
make.centerY.equalTo(commissionTitleLabel)
|
make.top.equalTo(commissionTitleLabel.snp.bottom).offset(2)
|
||||||
make.leading.equalTo(commissionTitleLabel.snp.trailing).offset(AppSpacing.sm)
|
make.centerX.equalToSuperview()
|
||||||
make.trailing.lessThanOrEqualTo(editCommissionButton.snp.leading).offset(-AppSpacing.xs)
|
make.leading.greaterThanOrEqualToSuperview().offset(AppSpacing.xs)
|
||||||
|
make.trailing.lessThanOrEqualToSuperview().inset(AppSpacing.xs)
|
||||||
}
|
}
|
||||||
editCommissionButton.snp.makeConstraints { make in
|
bindTimeIconView.snp.makeConstraints { make in
|
||||||
make.centerY.equalTo(commissionTitleLabel)
|
make.leading.equalTo(commissionContainer.snp.trailing).offset(AppSpacing.md)
|
||||||
make.trailing.equalToSuperview().inset(AppSpacing.xs)
|
make.centerY.equalTo(commissionContainer)
|
||||||
|
make.size.equalTo(18)
|
||||||
|
}
|
||||||
|
bindTimeValueLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(bindTimeIconView.snp.trailing).offset(AppSpacing.xs)
|
||||||
|
make.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.centerY.equalTo(bindTimeIconView)
|
||||||
|
}
|
||||||
|
dividerView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(commissionContainer.snp.bottom).offset(14)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.height.equalTo(0.5)
|
||||||
|
}
|
||||||
|
actionStack.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(dividerView.snp.bottom).offset(AppSpacing.sm)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.height.equalTo(AppSpacing.minTouchTarget)
|
||||||
|
make.bottom.equalToSuperview().inset(14)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -610,26 +658,52 @@ final class CooperationAcquirerCell: UITableViewCell {
|
|||||||
super.prepareForReuse()
|
super.prepareForReuse()
|
||||||
onEditRemark = nil
|
onEditRemark = nil
|
||||||
onEditCommission = nil
|
onEditCommission = nil
|
||||||
|
onViewCommissionLogs = nil
|
||||||
onCall = nil
|
onCall = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func configure(with acquirer: CooperativeSalerEntity) {
|
func configure(with acquirer: CooperativeSalerEntity) {
|
||||||
nameValueLabel.text = acquirer.displayName.isEmpty ? "—" : acquirer.displayName
|
nameValueLabel.text = acquirer.displayName.isEmpty ? "—" : acquirer.displayName
|
||||||
phoneValueLabel.text = acquirer.displayPhone.isEmpty ? "—" : CooperationOrderPhoneMask.mask(acquirer.displayPhone)
|
phoneValueLabel.text = acquirer.displayPhone.isEmpty ? "—" : CooperationOrderPhoneMask.mask(acquirer.displayPhone)
|
||||||
bindTimeValueLabel.text = acquirer.displayBindTime.isEmpty ? "—" : acquirer.displayBindTime
|
let bindTime = acquirer.displayBindTime.isEmpty ? "—" : Self.minutePrecisionTime(acquirer.displayBindTime)
|
||||||
|
bindTimeValueLabel.text = "绑定于 \(bindTime)"
|
||||||
commissionValueLabel.text = acquirer.displayCommissionRate.isEmpty ? "—" : acquirer.displayCommissionRate
|
commissionValueLabel.text = acquirer.displayCommissionRate.isEmpty ? "—" : acquirer.displayCommissionRate
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureTitleLabel(_ label: UILabel, text: String) {
|
private func configureActionButton(
|
||||||
label.text = text
|
_ button: UIButton,
|
||||||
label.font = .systemFont(ofSize: 13)
|
title: String,
|
||||||
label.textColor = AppColor.textTertiary
|
isPrimary: Bool,
|
||||||
label.setContentHuggingPriority(.required, for: .horizontal)
|
accessibilityIdentifier: String
|
||||||
label.setContentCompressionResistancePriority(.required, for: .horizontal)
|
) {
|
||||||
|
var configuration = UIButton.Configuration.plain()
|
||||||
|
configuration.title = title
|
||||||
|
configuration.baseForegroundColor = isPrimary ? AppColor.primary : UIColor(hex: 0x5F6673)
|
||||||
|
configuration.background.backgroundColor = isPrimary ? AppColor.primaryLight : .white
|
||||||
|
configuration.background.strokeColor = isPrimary
|
||||||
|
? AppColor.primary.withAlphaComponent(0.35)
|
||||||
|
: UIColor(hex: 0xD9DEE7)
|
||||||
|
configuration.background.strokeWidth = 1
|
||||||
|
configuration.background.cornerRadius = AppRadius.md
|
||||||
|
configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 6, bottom: 0, trailing: 6)
|
||||||
|
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
|
||||||
|
var updated = attributes
|
||||||
|
updated.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
button.configuration = configuration
|
||||||
|
button.accessibilityIdentifier = accessibilityIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func minutePrecisionTime(_ value: String) -> String {
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard trimmed.count == 19, trimmed.dropFirst(16).first == ":" else { return trimmed }
|
||||||
|
return String(trimmed.prefix(16))
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func editRemarkTapped() { onEditRemark?() }
|
@objc private func editRemarkTapped() { onEditRemark?() }
|
||||||
@objc private func editCommissionTapped() { onEditCommission?() }
|
@objc private func editCommissionTapped() { onEditCommission?() }
|
||||||
|
@objc private func commissionLogTapped() { onViewCommissionLogs?() }
|
||||||
@objc private func callTapped() { onCall?() }
|
@objc private func callTapped() { onCall?() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
277
suixinkanTests/CommissionRateLogTests.swift
Normal file
277
suixinkanTests/CommissionRateLogTests.swift
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
//
|
||||||
|
// CommissionRateLogTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
/// 获客员分成比例修改记录模型、接口与分页行为测试。
|
||||||
|
@MainActor
|
||||||
|
final class CommissionRateLogTests: XCTestCase {
|
||||||
|
func testModelDecodesCompleteRecordAndBuildsBusinessDisplayText() throws {
|
||||||
|
let data = """
|
||||||
|
{
|
||||||
|
"list": [{
|
||||||
|
"id": 1,
|
||||||
|
"binding_id": 10,
|
||||||
|
"sale_user_id": 456,
|
||||||
|
"store_user_id": 732,
|
||||||
|
"before_rate": 10,
|
||||||
|
"before_rate_label": "10%(旧)",
|
||||||
|
"after_rate": 20,
|
||||||
|
"after_rate_label": "20%(新)",
|
||||||
|
"operator_type": "store_user",
|
||||||
|
"operator_id": 732,
|
||||||
|
"operator_name": "李师傅",
|
||||||
|
"remark": " 调整合作比例 ",
|
||||||
|
"created_at": "2026-07-23 11:00:00"
|
||||||
|
}],
|
||||||
|
"total": 1,
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 10
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let response = try JSONDecoder().decode(CommissionRateLogListResponse.self, from: data)
|
||||||
|
let item = try XCTUnwrap(response.list.first)
|
||||||
|
|
||||||
|
XCTAssertEqual(response.pageSize, 10)
|
||||||
|
XCTAssertEqual(item.bindingId, 10)
|
||||||
|
XCTAssertEqual(item.saleUserId, 456)
|
||||||
|
XCTAssertEqual(item.storeUserId, 732)
|
||||||
|
XCTAssertEqual(item.operatorType, "store_user")
|
||||||
|
XCTAssertEqual(item.operatorId, 732)
|
||||||
|
XCTAssertEqual(item.displayBeforeRate, "10%(旧)")
|
||||||
|
XCTAssertEqual(item.displayAfterRate, "20%(新)")
|
||||||
|
XCTAssertEqual(item.metadataLine, "2026-07-23 11:00")
|
||||||
|
XCTAssertEqual(item.displayRemark, "调整合作比例")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDisplayFallsBackToNumericRateUnknownOperatorAndRawTime() throws {
|
||||||
|
let data = """
|
||||||
|
{
|
||||||
|
"list": [{
|
||||||
|
"id": 2,
|
||||||
|
"binding_id": 0,
|
||||||
|
"sale_user_id": 456,
|
||||||
|
"store_user_id": 0,
|
||||||
|
"before_rate": 5,
|
||||||
|
"before_rate_label": "",
|
||||||
|
"after_rate": 15,
|
||||||
|
"after_rate_label": " ",
|
||||||
|
"operator_type": "",
|
||||||
|
"operator_id": 0,
|
||||||
|
"operator_name": "",
|
||||||
|
"remark": " ",
|
||||||
|
"created_at": "服务端时间"
|
||||||
|
}],
|
||||||
|
"total": 1,
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 10
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let item = try JSONDecoder().decode(CommissionRateLogListResponse.self, from: data).list[0]
|
||||||
|
|
||||||
|
XCTAssertEqual(item.displayBeforeRate, "5%")
|
||||||
|
XCTAssertEqual(item.displayAfterRate, "15%")
|
||||||
|
XCTAssertEqual(item.metadataLine, "服务端时间")
|
||||||
|
XCTAssertNil(item.displayRemark)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAPIBuildsPathAndClampsPaginationParameters() async throws {
|
||||||
|
let session = MockURLSession(responses: [envelope(list: [], total: 0, page: 1)])
|
||||||
|
let api = OrderAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
_ = try await api.saleUserCommissionRateLogs(saleUserId: 456, page: 0, pageSize: 99)
|
||||||
|
|
||||||
|
let request = try XCTUnwrap(session.requests.first)
|
||||||
|
XCTAssertEqual(request.httpMethod, "GET")
|
||||||
|
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/sale-user/commission-rate/logs")
|
||||||
|
let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems
|
||||||
|
XCTAssertEqual(query?.first { $0.name == "sale_user_id" }?.value, "456")
|
||||||
|
XCTAssertEqual(query?.first { $0.name == "page" }?.value, "1")
|
||||||
|
XCTAssertEqual(query?.first { $0.name == "page_size" }?.value, "50")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testViewModelRetriesFailedPageAndDeduplicatesItems() async throws {
|
||||||
|
let page1 = envelope(
|
||||||
|
list: [
|
||||||
|
record(id: 1, before: 10, after: 20),
|
||||||
|
record(id: 1, before: 10, after: 20),
|
||||||
|
record(id: 2, before: 20, after: 30),
|
||||||
|
],
|
||||||
|
total: 3,
|
||||||
|
page: 1
|
||||||
|
)
|
||||||
|
let page2 = envelope(
|
||||||
|
list: [
|
||||||
|
record(id: 2, before: 20, after: 30),
|
||||||
|
record(id: 3, before: 30, after: 40),
|
||||||
|
],
|
||||||
|
total: 3,
|
||||||
|
page: 2
|
||||||
|
)
|
||||||
|
let response = HTTPURLResponse(
|
||||||
|
url: URL(string: "https://api-test.zhifly.cn/mock")!,
|
||||||
|
statusCode: 200,
|
||||||
|
httpVersion: nil,
|
||||||
|
headerFields: nil
|
||||||
|
)!
|
||||||
|
let session = MockURLSession(results: [
|
||||||
|
.success((page1, response)),
|
||||||
|
.failure(URLError(.timedOut)),
|
||||||
|
.success((page2, response)),
|
||||||
|
])
|
||||||
|
let api = OrderAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
let viewModel = CommissionRateLogViewModel(saleUserId: 456)
|
||||||
|
|
||||||
|
await viewModel.refresh(api: api)
|
||||||
|
await viewModel.loadMore(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.items.map(\.id), [1, 2])
|
||||||
|
XCTAssertTrue(viewModel.canLoadMore)
|
||||||
|
XCTAssertFalse(viewModel.isLoadingMore)
|
||||||
|
|
||||||
|
await viewModel.loadMore(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.items.map(\.id), [1, 2, 3])
|
||||||
|
XCTAssertFalse(viewModel.canLoadMore)
|
||||||
|
let requestedPages = session.requests.compactMap { request in
|
||||||
|
URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?
|
||||||
|
.queryItems?
|
||||||
|
.first { $0.name == "page" }?
|
||||||
|
.value
|
||||||
|
}
|
||||||
|
XCTAssertEqual(requestedPages, ["1", "2", "2"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTimelineCellOnlyMarksLatestRecord() {
|
||||||
|
let item = CommissionRateLogEntity(
|
||||||
|
id: 1,
|
||||||
|
bindingId: 10,
|
||||||
|
saleUserId: 456,
|
||||||
|
storeUserId: 732,
|
||||||
|
beforeRate: 10,
|
||||||
|
beforeRateLabel: "10%",
|
||||||
|
afterRate: 20,
|
||||||
|
afterRateLabel: "20%",
|
||||||
|
operatorType: "store_user",
|
||||||
|
operatorId: 732,
|
||||||
|
operatorName: "李师傅",
|
||||||
|
remark: "",
|
||||||
|
createdAt: "2026-07-23 11:00:00"
|
||||||
|
)
|
||||||
|
let cell = CommissionRateLogTimelineCell(style: .default, reuseIdentifier: nil)
|
||||||
|
|
||||||
|
cell.configure(with: item, isLatest: true)
|
||||||
|
XCTAssertTrue(cell.accessibilityLabel?.contains("最新记录") == true)
|
||||||
|
XCTAssertFalse(cell.accessibilityLabel?.contains("李师傅") == true)
|
||||||
|
|
||||||
|
cell.configure(with: item, isLatest: false)
|
||||||
|
XCTAssertFalse(cell.accessibilityLabel?.contains("最新记录") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPageTitleUsesAcquirerNameAndFallsBackWhenEmpty() {
|
||||||
|
let namedController = CommissionRateLogViewController(
|
||||||
|
saleUserId: 456,
|
||||||
|
acquirerName: " 获客员小王 "
|
||||||
|
)
|
||||||
|
namedController.setupNavigationBar()
|
||||||
|
XCTAssertEqual(namedController.title, "给获客员小王的分成记录")
|
||||||
|
|
||||||
|
let fallbackController = CommissionRateLogViewController(
|
||||||
|
saleUserId: 456,
|
||||||
|
acquirerName: " "
|
||||||
|
)
|
||||||
|
fallbackController.setupNavigationBar()
|
||||||
|
XCTAssertEqual(fallbackController.title, "修改记录")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAcquirerCardUsesUnifiedActionsAndBusinessHierarchy() throws {
|
||||||
|
let cell = CooperationAcquirerCell(style: .default, reuseIdentifier: nil)
|
||||||
|
cell.configure(
|
||||||
|
with: CooperativeSalerEntity(
|
||||||
|
saleUserId: 456,
|
||||||
|
name: "获客员小王",
|
||||||
|
salerPhone: "13800138000",
|
||||||
|
commissionRate: 20,
|
||||||
|
commissionRateLabel: "20%",
|
||||||
|
bindTime: "2026-07-23 11:00:00"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let fittingSize = cell.contentView.systemLayoutSizeFitting(
|
||||||
|
CGSize(width: 390, height: UIView.layoutFittingCompressedSize.height),
|
||||||
|
withHorizontalFittingPriority: .required,
|
||||||
|
verticalFittingPriority: .fittingSizeLevel
|
||||||
|
)
|
||||||
|
cell.frame = CGRect(x: 0, y: 0, width: 390, height: fittingSize.height)
|
||||||
|
cell.layoutIfNeeded()
|
||||||
|
|
||||||
|
let remarkButton = try XCTUnwrap(
|
||||||
|
subview(in: cell, identifier: "cooperation_acquirer_edit_remark") as? UIButton
|
||||||
|
)
|
||||||
|
let logButton = try XCTUnwrap(
|
||||||
|
subview(in: cell, identifier: "cooperation_acquirer_commission_logs") as? UIButton
|
||||||
|
)
|
||||||
|
let commissionButton = try XCTUnwrap(
|
||||||
|
subview(in: cell, identifier: "cooperation_acquirer_edit_commission") as? UIButton
|
||||||
|
)
|
||||||
|
let bindTimeLabel = try XCTUnwrap(
|
||||||
|
subview(in: cell, identifier: "cooperation_acquirer_bind_time") as? UILabel
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(remarkButton.bounds.height, AppSpacing.minTouchTarget, accuracy: 0.5)
|
||||||
|
XCTAssertEqual(logButton.bounds.height, remarkButton.bounds.height, accuracy: 0.5)
|
||||||
|
XCTAssertEqual(commissionButton.bounds.height, remarkButton.bounds.height, accuracy: 0.5)
|
||||||
|
XCTAssertEqual(logButton.bounds.width, remarkButton.bounds.width, accuracy: 0.5)
|
||||||
|
XCTAssertEqual(commissionButton.bounds.width, remarkButton.bounds.width, accuracy: 0.5)
|
||||||
|
XCTAssertEqual(bindTimeLabel.text, "绑定于 2026-07-23 11:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func envelope(list: [[String: Any]], total: Int, page: Int) -> Data {
|
||||||
|
let object: [String: Any] = [
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": [
|
||||||
|
"list": list,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": 10,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
return try! JSONSerialization.data(withJSONObject: object)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func record(id: Int, before: Int, after: Int) -> [String: Any] {
|
||||||
|
[
|
||||||
|
"id": id,
|
||||||
|
"binding_id": 10,
|
||||||
|
"sale_user_id": 456,
|
||||||
|
"store_user_id": 732,
|
||||||
|
"before_rate": before,
|
||||||
|
"before_rate_label": "\(before)%",
|
||||||
|
"after_rate": after,
|
||||||
|
"after_rate_label": "\(after)%",
|
||||||
|
"operator_type": "store_user",
|
||||||
|
"operator_id": 732,
|
||||||
|
"operator_name": "李师傅",
|
||||||
|
"remark": "",
|
||||||
|
"created_at": "2026-07-23 11:00:00",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func subview(in root: UIView, identifier: String) -> UIView? {
|
||||||
|
if root.accessibilityIdentifier == identifier {
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
for child in root.subviews {
|
||||||
|
if let match = subview(in: child, identifier: identifier) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user