From 0e0415eec4e5e82f7759d5f666ed22d018d270de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Thu, 23 Jul 2026 17:43:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=8E=B7=E5=AE=A2?= =?UTF-8?q?=E5=91=98=E5=88=86=E6=88=90=E8=AE=B0=E5=BD=95=E5=B9=B6=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CommissionRateLogViewModel.swift | 94 ++++++ suixinkan/Features/Orders/API/OrderAPI.swift | 22 ++ .../Features/Orders/Models/OrderModels.swift | 87 ++++++ .../CommissionRateLogViewController.swift | 284 ++++++++++++++++++ .../CooperationAcquirerViewController.swift | 18 +- .../Views/CooperationOrderViews.swift | 258 ++++++++++------ suixinkanTests/CommissionRateLogTests.swift | 277 +++++++++++++++++ 7 files changed, 945 insertions(+), 95 deletions(-) create mode 100644 suixinkan/Features/CooperationOrder/ViewModels/CommissionRateLogViewModel.swift create mode 100644 suixinkan/UI/CooperationOrder/CommissionRateLogViewController.swift create mode 100644 suixinkanTests/CommissionRateLogTests.swift diff --git a/suixinkan/Features/CooperationOrder/ViewModels/CommissionRateLogViewModel.swift b/suixinkan/Features/CooperationOrder/ViewModels/CommissionRateLogViewModel.swift new file mode 100644 index 0000000..11d7ce9 --- /dev/null +++ b/suixinkan/Features/CooperationOrder/ViewModels/CommissionRateLogViewModel.swift @@ -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() + 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?() + } +} diff --git a/suixinkan/Features/Orders/API/OrderAPI.swift b/suixinkan/Features/Orders/API/OrderAPI.swift index 6bf7d1e..ad42527 100644 --- a/suixinkan/Features/Orders/API/OrderAPI.swift +++ b/suixinkan/Features/Orders/API/OrderAPI.swift @@ -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 { try await client.send( APIRequest( diff --git a/suixinkan/Features/Orders/Models/OrderModels.swift b/suixinkan/Features/Orders/Models/OrderModels.swift index ff5ee2e..855718d 100644 --- a/suixinkan/Features/Orders/Models/OrderModels.swift +++ b/suixinkan/Features/Orders/Models/OrderModels.swift @@ -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 { let total: Int let list: [ReferralOrderEntity] diff --git a/suixinkan/UI/CooperationOrder/CommissionRateLogViewController.swift b/suixinkan/UI/CooperationOrder/CommissionRateLogViewController.swift new file mode 100644 index 0000000..28d5519 --- /dev/null +++ b/suixinkan/UI/CooperationOrder/CommissionRateLogViewController.swift @@ -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! + + 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( + 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() + 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: ",") + } +} diff --git a/suixinkan/UI/CooperationOrder/CooperationAcquirerViewController.swift b/suixinkan/UI/CooperationOrder/CooperationAcquirerViewController.swift index 6e3ea9a..f3bf992 100644 --- a/suixinkan/UI/CooperationOrder/CooperationAcquirerViewController.swift +++ b/suixinkan/UI/CooperationOrder/CooperationAcquirerViewController.swift @@ -31,12 +31,13 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa } override func setupUI() { + view.backgroundColor = UIColor(hex: 0xF6F7FA) tableView.backgroundColor = .clear tableView.separatorStyle = .none tableView.rowHeight = UITableView.automaticDimension - tableView.estimatedRowHeight = 142 - tableView.contentInset.top = AppSpacing.sm - tableView.verticalScrollIndicatorInsets.top = AppSpacing.sm + tableView.estimatedRowHeight = 224 + tableView.contentInset = UIEdgeInsets(top: AppSpacing.sm, left: 0, bottom: AppSpacing.md, right: 0) + tableView.verticalScrollIndicatorInsets = UIEdgeInsets(top: AppSpacing.sm, left: 0, bottom: AppSpacing.md, right: 0) tableView.dataSource = self tableView.delegate = self tableView.register(CooperationAcquirerCell.self, forCellReuseIdentifier: CooperationAcquirerCell.reuseIdentifier) @@ -245,6 +246,17 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa cell.onEditCommission = { [weak self] in 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 = { let phone = acquirer.displayPhone.trimmingCharacters(in: .whitespacesAndNewlines) guard !phone.isEmpty, let url = URL(string: "tel://\(phone)") else { return } diff --git a/suixinkan/UI/CooperationOrder/Views/CooperationOrderViews.swift b/suixinkan/UI/CooperationOrder/Views/CooperationOrderViews.swift index 459a2bb..7fb28bc 100644 --- a/suixinkan/UI/CooperationOrder/Views/CooperationOrderViews.swift +++ b/suixinkan/UI/CooperationOrder/Views/CooperationOrderViews.swift @@ -445,22 +445,25 @@ final class CooperationAcquirerCell: UITableViewCell { var onEditRemark: (() -> Void)? var onEditCommission: (() -> Void)? + var onViewCommissionLogs: (() -> Void)? var onCall: (() -> Void)? private let cardView = UIView() private let avatarContainer = UIView() private let avatarIconView = UIImageView(image: UIImage(systemName: "person.fill")) - private let nameTitleLabel = UILabel() private let nameValueLabel = UILabel() - private let editRemarkButton = UIButton(type: .system) - private let phoneTitleLabel = UILabel() private let phoneValueLabel = UILabel() private let callButton = UIButton(type: .system) - private let bindTimeTitleLabel = UILabel() - private let bindTimeValueLabel = UILabel() + private let commissionContainer = UIView() private let commissionTitleLabel = 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 commissionLogButton = UIButton(type: .system) + private let actionStack = UIStackView() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -469,135 +472,180 @@ final class CooperationAcquirerCell: UITableViewCell { contentView.backgroundColor = .clear 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.shadowOpacity = 0.04 - cardView.layer.shadowOffset = CGSize(width: 0, height: 1) - cardView.layer.shadowRadius = 2 + cardView.layer.shadowOffset = CGSize(width: 0, height: 3) + cardView.layer.shadowRadius = 8 + cardView.accessibilityIdentifier = "cooperation_acquirer_card" avatarContainer.backgroundColor = AppColor.primary.withAlphaComponent(0.08) - avatarContainer.layer.cornerRadius = AppRadius.sm + avatarContainer.layer.cornerRadius = 24 avatarContainer.clipsToBounds = true avatarIconView.tintColor = AppColor.primary avatarIconView.contentMode = .scaleAspectFit - configureTitleLabel(nameTitleLabel, text: "获客员名称") - configureTitleLabel(phoneTitleLabel, text: "手机号") - configureTitleLabel(bindTimeTitleLabel, text: "绑定时间") - configureTitleLabel(commissionTitleLabel, text: "分成比例") - - nameValueLabel.font = .systemFont(ofSize: 15, weight: .bold) + nameValueLabel.font = .systemFont(ofSize: 17, weight: .semibold) nameValueLabel.textColor = AppColor.textPrimary nameValueLabel.numberOfLines = 1 nameValueLabel.lineBreakMode = .byTruncatingTail + nameValueLabel.accessibilityIdentifier = "cooperation_acquirer_name" - [phoneValueLabel, bindTimeValueLabel, commissionValueLabel].forEach { label in - label.font = .systemFont(ofSize: 13) - label.textColor = AppColor.textSecondary - label.numberOfLines = 1 - label.lineBreakMode = .byTruncatingTail - } - 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) + phoneValueLabel.font = .systemFont(ofSize: 14) + phoneValueLabel.textColor = AppColor.textTertiary + phoneValueLabel.numberOfLines = 1 + phoneValueLabel.lineBreakMode = .byTruncatingTail + phoneValueLabel.accessibilityIdentifier = "cooperation_acquirer_phone" callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal) callButton.tintColor = AppColor.primary 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.accessibilityLabel = "拨打获客员电话" + callButton.accessibilityIdentifier = "cooperation_acquirer_call" callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside) - editCommissionButton.setTitle("修改比例", for: .normal) - editCommissionButton.titleLabel?.font = .systemFont(ofSize: 13) - editCommissionButton.setTitleColor(AppColor.primary, for: .normal) - editCommissionButton.setConfigurationContentInsets( - NSDirectionalEdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8) + commissionContainer.backgroundColor = AppColor.primary.withAlphaComponent(0.06) + commissionContainer.layer.cornerRadius = AppRadius.lg + + commissionTitleLabel.text = "分成比例" + 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) + 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) cardView.addSubview(avatarContainer) avatarContainer.addSubview(avatarIconView) - cardView.addSubview(nameTitleLabel) cardView.addSubview(nameValueLabel) - cardView.addSubview(editRemarkButton) - cardView.addSubview(phoneTitleLabel) cardView.addSubview(phoneValueLabel) cardView.addSubview(callButton) - cardView.addSubview(bindTimeTitleLabel) + cardView.addSubview(commissionContainer) + commissionContainer.addSubview(commissionTitleLabel) + commissionContainer.addSubview(commissionValueLabel) + cardView.addSubview(bindTimeIconView) cardView.addSubview(bindTimeValueLabel) - cardView.addSubview(commissionTitleLabel) - cardView.addSubview(commissionValueLabel) - cardView.addSubview(editCommissionButton) + cardView.addSubview(dividerView) + cardView.addSubview(actionStack) 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 - make.leading.equalToSuperview().offset(14) - make.centerY.equalToSuperview() - make.size.equalTo(44) + make.top.leading.equalToSuperview().inset(AppSpacing.md) + make.size.equalTo(48) } avatarIconView.snp.makeConstraints { make in make.center.equalToSuperview() - make.size.equalTo(22) - } - nameTitleLabel.snp.makeConstraints { make in - make.top.equalToSuperview().offset(AppSpacing.md) - make.leading.equalTo(avatarContainer.snp.trailing).offset(AppSpacing.sm) + make.size.equalTo(24) } nameValueLabel.snp.makeConstraints { make in - make.centerY.equalTo(nameTitleLabel) - make.leading.equalTo(nameTitleLabel.snp.trailing).offset(AppSpacing.sm) - make.trailing.lessThanOrEqualTo(editRemarkButton.snp.leading).offset(-AppSpacing.xs) - } - 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) + make.top.equalTo(avatarContainer).offset(2) + make.leading.equalTo(avatarContainer.snp.trailing).offset(AppSpacing.sm) + make.trailing.lessThanOrEqualTo(callButton.snp.leading).offset(-AppSpacing.sm) } phoneValueLabel.snp.makeConstraints { make in - make.centerY.equalTo(phoneTitleLabel) - make.leading.equalTo(phoneTitleLabel.snp.trailing).offset(AppSpacing.sm) - make.trailing.lessThanOrEqualTo(callButton.snp.leading).offset(-AppSpacing.xs) + make.top.equalTo(nameValueLabel.snp.bottom).offset(AppSpacing.xxs) + make.leading.equalTo(nameValueLabel) + make.trailing.lessThanOrEqualTo(callButton.snp.leading).offset(-AppSpacing.sm) } callButton.snp.makeConstraints { make in - make.trailing.equalToSuperview().inset(14) - make.centerY.equalTo(phoneTitleLabel) - make.size.equalTo(30) + make.top.trailing.equalToSuperview().inset(AppSpacing.md) + make.size.equalTo(AppSpacing.minTouchTarget) } - bindTimeTitleLabel.snp.makeConstraints { make in - make.top.equalTo(phoneTitleLabel.snp.bottom).offset(AppSpacing.xs) - make.leading.equalTo(nameTitleLabel) - } - bindTimeValueLabel.snp.makeConstraints { make in - make.centerY.equalTo(bindTimeTitleLabel) - make.leading.equalTo(bindTimeTitleLabel.snp.trailing).offset(AppSpacing.sm) - make.trailing.lessThanOrEqualToSuperview().inset(14) + commissionContainer.snp.makeConstraints { make in + make.top.equalTo(avatarContainer.snp.bottom).offset(14) + make.leading.equalToSuperview().inset(AppSpacing.md) + make.width.equalTo(120) + make.height.equalTo(64) } commissionTitleLabel.snp.makeConstraints { make in - make.top.equalTo(bindTimeTitleLabel.snp.bottom).offset(AppSpacing.xs) - make.leading.equalTo(nameTitleLabel) - make.bottom.equalToSuperview().inset(AppSpacing.md) + make.top.equalToSuperview().offset(AppSpacing.xs) + make.centerX.equalToSuperview() } commissionValueLabel.snp.makeConstraints { make in - make.centerY.equalTo(commissionTitleLabel) - make.leading.equalTo(commissionTitleLabel.snp.trailing).offset(AppSpacing.sm) - make.trailing.lessThanOrEqualTo(editCommissionButton.snp.leading).offset(-AppSpacing.xs) + make.top.equalTo(commissionTitleLabel.snp.bottom).offset(2) + make.centerX.equalToSuperview() + make.leading.greaterThanOrEqualToSuperview().offset(AppSpacing.xs) + make.trailing.lessThanOrEqualToSuperview().inset(AppSpacing.xs) } - editCommissionButton.snp.makeConstraints { make in - make.centerY.equalTo(commissionTitleLabel) - make.trailing.equalToSuperview().inset(AppSpacing.xs) + bindTimeIconView.snp.makeConstraints { make in + make.leading.equalTo(commissionContainer.snp.trailing).offset(AppSpacing.md) + 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() onEditRemark = nil onEditCommission = nil + onViewCommissionLogs = nil onCall = nil } func configure(with acquirer: CooperativeSalerEntity) { nameValueLabel.text = acquirer.displayName.isEmpty ? "—" : acquirer.displayName 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 } - private func configureTitleLabel(_ label: UILabel, text: String) { - label.text = text - label.font = .systemFont(ofSize: 13) - label.textColor = AppColor.textTertiary - label.setContentHuggingPriority(.required, for: .horizontal) - label.setContentCompressionResistancePriority(.required, for: .horizontal) + private func configureActionButton( + _ button: UIButton, + title: String, + isPrimary: Bool, + accessibilityIdentifier: String + ) { + 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 editCommissionTapped() { onEditCommission?() } + @objc private func commissionLogTapped() { onViewCommissionLogs?() } @objc private func callTapped() { onCall?() } } diff --git a/suixinkanTests/CommissionRateLogTests.swift b/suixinkanTests/CommissionRateLogTests.swift new file mode 100644 index 0000000..a490b41 --- /dev/null +++ b/suixinkanTests/CommissionRateLogTests.swift @@ -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 + } +}