// // OfflineCollectionDailyViewController.swift // suixinkan // import SnapKit import UIKit /// 日清列表的分区。 enum OfflineDailyListSection: Hashable { case records case batches case empty } /// 日清列表的条目数据。 enum OfflineDailyListItem: Hashable { case record(OfflineCollectionRecord) case batch(OfflineSettlementBatch) case empty(isToday: Bool) } /// 线下收款日清页,展示单营业日汇总、明细与独立补缴流水。 final class OfflineCollectionDailyViewController: BaseViewController { private let viewModel: OfflineCollectionDailyViewModel private let headerStack = UIStackView() private let dateControl = UIButton(type: .system) private let summaryCard = UIView() private let summaryStatusLabel = UILabel() private let totalMetric = OfflineDailyMetricView(title: "线下收款总额") private let countMetric = OfflineDailyMetricView(title: "登记笔数") private let settledMetric = OfflineDailyMetricView(title: "已补缴") private let pendingMetric = OfflineDailyMetricView(title: "待补缴") private let overdueBanner = UILabel() private let tableView = UITableView(frame: .zero, style: .plain) private var dataSource: UITableViewDiffableDataSource! private var currentSections: [OfflineDailyListSection] = [] private let bottomContainer = UIView() private let settlementButton = UIButton(type: .system) private var isFeedbackPresented = false /// 使用指定营业日、收款上下文与 Mock 服务创建日清页。 init( businessDate: String, context: OfflineCollectionContext = .current(), service: OfflineCollectionMockService = .shared ) { viewModel = OfflineCollectionDailyViewModel( businessDate: businessDate, context: context, service: service ) super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func setupNavigationBar() { title = "线下收款日清" #if DEBUG navigationItem.rightBarButtonItem = UIBarButtonItem( title: "模拟失败", style: .plain, target: self, action: #selector(simulateFailureTapped) ) #endif } override func setupUI() { view.backgroundColor = AppColor.pageBackground headerStack.axis = .vertical headerStack.spacing = AppSpacing.sm dateControl.contentHorizontalAlignment = .leading dateControl.showsMenuAsPrimaryAction = true var dateConfiguration = UIButton.Configuration.plain() dateConfiguration.image = UIImage(systemName: "calendar") dateConfiguration.imagePadding = AppSpacing.sm dateConfiguration.baseForegroundColor = AppColor.textPrimary dateConfiguration.background.backgroundColor = .white dateConfiguration.background.cornerRadius = AppRadius.lg dateConfiguration.contentInsets = NSDirectionalEdgeInsets( top: 12, leading: AppSpacing.md, bottom: 12, trailing: AppSpacing.md ) dateControl.configuration = dateConfiguration dateControl.accessibilityIdentifier = "offlineCollection.businessDate" summaryCard.backgroundColor = .white summaryCard.layer.cornerRadius = AppRadius.lg summaryStatusLabel.font = .systemFont(ofSize: 13, weight: .semibold) summaryStatusLabel.textAlignment = .center summaryStatusLabel.textColor = AppColor.success summaryStatusLabel.backgroundColor = UIColor(hex: 0xECFDF3) summaryStatusLabel.layer.cornerRadius = 12 summaryStatusLabel.clipsToBounds = true let firstMetricRow = UIStackView(arrangedSubviews: [totalMetric, countMetric]) firstMetricRow.axis = .horizontal firstMetricRow.distribution = .fillEqually let secondMetricRow = UIStackView(arrangedSubviews: [settledMetric, pendingMetric]) secondMetricRow.axis = .horizontal secondMetricRow.distribution = .fillEqually let metricStack = UIStackView(arrangedSubviews: [firstMetricRow, secondMetricRow]) metricStack.axis = .vertical metricStack.spacing = AppSpacing.md summaryCard.addSubview(metricStack) summaryCard.addSubview(summaryStatusLabel) metricStack.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.bottom.equalToSuperview().inset(AppSpacing.md) } summaryStatusLabel.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(AppSpacing.md) make.height.equalTo(24) } overdueBanner.text = "该营业日存在逾期未补缴,请尽快完成补缴" overdueBanner.font = .systemFont(ofSize: 13, weight: .medium) overdueBanner.textColor = AppColor.danger overdueBanner.backgroundColor = UIColor(hex: 0xFFF1F0) overdueBanner.layer.cornerRadius = AppRadius.md overdueBanner.clipsToBounds = true overdueBanner.textAlignment = .center overdueBanner.numberOfLines = 0 tableView.backgroundColor = .clear tableView.separatorStyle = .none tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 92 tableView.delegate = self tableView.register(OfflineCollectionRecordCell.self, forCellReuseIdentifier: OfflineCollectionRecordCell.reuseIdentifier) tableView.register(OfflineSettlementBatchCell.self, forCellReuseIdentifier: OfflineSettlementBatchCell.reuseIdentifier) tableView.register(OfflineCollectionEmptyCell.self, forCellReuseIdentifier: OfflineCollectionEmptyCell.reuseIdentifier) dataSource = makeDataSource() bottomContainer.backgroundColor = .white bottomContainer.layer.shadowColor = UIColor.black.cgColor bottomContainer.layer.shadowOpacity = 0.06 bottomContainer.layer.shadowRadius = 8 bottomContainer.layer.shadowOffset = CGSize(width: 0, height: -2) var settlementConfiguration = UIButton.Configuration.filled() settlementConfiguration.title = "补缴今日全部" settlementConfiguration.baseBackgroundColor = AppColor.primary settlementConfiguration.baseForegroundColor = .white settlementConfiguration.cornerStyle = .medium settlementConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in var outgoing = incoming outgoing.font = .systemFont(ofSize: 17, weight: .semibold) return outgoing } settlementButton.configuration = settlementConfiguration settlementButton.accessibilityIdentifier = "offlineCollection.settle" view.addSubview(headerStack) [dateControl, summaryCard, overdueBanner].forEach(headerStack.addArrangedSubview) view.addSubview(tableView) view.addSubview(bottomContainer) bottomContainer.addSubview(settlementButton) } override func setupConstraints() { headerStack.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm) make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset) } dateControl.snp.makeConstraints { make in make.height.equalTo(50) } summaryCard.snp.makeConstraints { make in make.height.equalTo(172) } overdueBanner.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(48) } bottomContainer.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } settlementButton.snp.makeConstraints { make in make.top.equalToSuperview().inset(AppSpacing.md) make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset) make.height.equalTo(50) make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.md) } tableView.snp.makeConstraints { make in make.top.equalTo(headerStack.snp.bottom).offset(AppSpacing.sm) make.leading.trailing.equalToSuperview() make.bottom.equalTo(bottomContainer.snp.top) } } override func bindActions() { settlementButton.addTarget(self, action: #selector(settlementTapped), for: .touchUpInside) viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyViewModel() } } } override func viewDidLoad() { super.viewDidLoad() applyViewModel() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) viewModel.load() } @MainActor private func applyViewModel() { let datePrefix = viewModel.isToday ? "今日 · " : "营业日 · " dateControl.configuration?.title = datePrefix + viewModel.businessDate dateControl.menu = makeDateMenu() totalMetric.setValue(OfflineCollectionMoney.display(viewModel.summary.totalAmountFen)) countMetric.setValue("\(viewModel.summary.totalCount) 笔") settledMetric.setValue(OfflineCollectionMoney.display(viewModel.summary.settledAmountFen)) pendingMetric.setValue(OfflineCollectionMoney.display(viewModel.summary.pendingAmountFen), highlighted: viewModel.summary.pendingAmountFen > 0) let isSettled = viewModel.summary.totalCount > 0 && viewModel.summary.pendingAmountFen == 0 summaryStatusLabel.text = " 本日已结清 " summaryStatusLabel.isHidden = !isSettled overdueBanner.isHidden = !viewModel.summary.hasOverdue let isProcessing = viewModel.settlementState == .processing settlementButton.isEnabled = viewModel.canSettle var configuration = settlementButton.configuration configuration?.showsActivityIndicator = isProcessing if isProcessing { configuration?.title = "补缴中" } else if isSettled { configuration?.title = "本日已全部补缴" } else if viewModel.summary.pendingAmountFen == 0 { configuration?.title = "当日暂无待补缴" } else { let prefix = viewModel.isToday ? "补缴今日全部" : "补缴本日全部" configuration?.title = "\(prefix) \(OfflineCollectionMoney.display(viewModel.summary.pendingAmountFen))" } settlementButton.configuration = configuration settlementButton.alpha = settlementButton.isEnabled || isProcessing ? 1 : 0.5 applySnapshot() presentSettlementFeedbackIfNeeded() } @MainActor private func applySnapshot() { var snapshot = NSDiffableDataSourceSnapshot() currentSections = [] if viewModel.records.isEmpty { currentSections.append(.empty) snapshot.appendSections([.empty]) snapshot.appendItems([.empty(isToday: viewModel.isToday)], toSection: .empty) } else { currentSections.append(.records) snapshot.appendSections([.records]) snapshot.appendItems(viewModel.records.map(OfflineDailyListItem.record), toSection: .records) } if !viewModel.batches.isEmpty { currentSections.append(.batches) snapshot.appendSections([.batches]) snapshot.appendItems(viewModel.batches.map(OfflineDailyListItem.batch), toSection: .batches) } dataSource.apply(snapshot, animatingDifferences: view.window != nil) } private func makeDataSource() -> UITableViewDiffableDataSource { UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in switch item { case let .record(record): guard let cell = tableView.dequeueReusableCell( withIdentifier: OfflineCollectionRecordCell.reuseIdentifier, for: indexPath ) as? OfflineCollectionRecordCell else { return UITableViewCell() } cell.apply(record: record) return cell case let .batch(batch): guard let cell = tableView.dequeueReusableCell( withIdentifier: OfflineSettlementBatchCell.reuseIdentifier, for: indexPath ) as? OfflineSettlementBatchCell else { return UITableViewCell() } cell.apply(batch: batch) return cell case let .empty(isToday): guard let cell = tableView.dequeueReusableCell( withIdentifier: OfflineCollectionEmptyCell.reuseIdentifier, for: indexPath ) as? OfflineCollectionEmptyCell else { return UITableViewCell() } cell.apply(isToday: isToday) cell.onRegister = { [weak self] in self?.openRegistration() } return cell } } } private func makeDateMenu() -> UIMenu { let actions = viewModel.availableBusinessDates.map { [weak self] date in let isCurrent = date == self?.viewModel.businessDate let title = date == self?.viewModel.service.todayBusinessDate ? "今日 \(date)" : date return UIAction(title: title, state: isCurrent ? .on : .off) { [weak self] _ in self?.viewModel.selectBusinessDate(date) } } return UIMenu(title: "选择营业日", children: actions) } @MainActor private func presentSettlementFeedbackIfNeeded() { guard !isFeedbackPresented else { return } switch viewModel.settlementState { case .idle, .processing: return case let .success(batch): isFeedbackPresented = true let message = """ 已补缴 \(OfflineCollectionMoney.display(batch.amountFen)) 共结清 \(batch.recordCount) 笔线下收款 补缴流水号:\(batch.id) """ let alert = UIAlertController(title: "补缴成功", message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "完成", style: .default) { [weak self] _ in self?.isFeedbackPresented = false self?.viewModel.clearSettlementFeedback() }) present(alert, animated: true) case let .failed(message): isFeedbackPresented = true let alert = UIAlertController(title: "补缴失败", message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "暂不处理", style: .cancel) { [weak self] _ in self?.isFeedbackPresented = false self?.viewModel.cancelPreparedSettlement() self?.viewModel.clearSettlementFeedback() }) alert.addAction(UIAlertAction(title: "重新补缴", style: .default) { [weak self] _ in guard let self else { return } self.isFeedbackPresented = false self.viewModel.clearSettlementFeedback() Task { await self.viewModel.confirmSettlement() } }) present(alert, animated: true) } } private func openRegistration() { guard viewModel.isToday else { return } navigationController?.pushViewController( OfflineCollectionRegistrationViewController( context: viewModel.context, service: viewModel.service ), animated: true ) } @objc private func settlementTapped() { do { let request = try viewModel.prepareSettlement() let message = """ 营业日:\(request.businessDate) 待补缴记录:\(request.recordIds.count) 笔 本次补缴金额:\(OfflineCollectionMoney.display(request.amountFen)) 补缴成功后,本批次记录将统一变为“已补缴”。 """ let alert = UIAlertController(title: "确认补缴", message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in self?.viewModel.cancelPreparedSettlement() }) alert.addAction(UIAlertAction( title: "确认补缴 \(OfflineCollectionMoney.display(request.amountFen))", style: .default ) { [weak self] _ in guard let self else { return } Task { await self.viewModel.confirmSettlement() } }) present(alert, animated: true) } catch { showToast(error.localizedDescription) viewModel.load() } } @objc private func simulateFailureTapped() { viewModel.simulateNextSettlementFailure() showToast("下一次补缴将模拟失败,可验收失败后重试") } } extension OfflineCollectionDailyViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { currentSections[safe: section] == .empty ? 0 : 40 } func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { guard let listSection = currentSections[safe: section], listSection != .empty else { return nil } let container = UIView() container.backgroundColor = AppColor.pageBackground let label = UILabel() label.text = listSection == .records ? "线下收款明细" : "线下收款补缴流水" label.font = .systemFont(ofSize: 15, weight: .semibold) label.textColor = AppColor.textPrimary container.addSubview(label) label.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset) make.bottom.equalToSuperview().inset(AppSpacing.xs) } return container } } /// 日清汇总卡片中的单项指标视图。 final class OfflineDailyMetricView: UIView { private let titleLabel = UILabel() private let valueLabel = UILabel() /// 使用指标名称创建汇总视图。 init(title: String) { super.init(frame: .zero) titleLabel.text = title setupUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 更新指标值及是否使用警示色强调。 func setValue(_ value: String, highlighted: Bool = false) { valueLabel.text = value valueLabel.textColor = highlighted ? UIColor(hex: 0xD97706) : AppColor.textPrimary } private func setupUI() { titleLabel.font = .systemFont(ofSize: 12) titleLabel.textColor = AppColor.textTertiary valueLabel.font = .systemFont(ofSize: 19, weight: .bold) valueLabel.textColor = AppColor.textPrimary valueLabel.adjustsFontSizeToFitWidth = true valueLabel.minimumScaleFactor = 0.75 let stack = UIStackView(arrangedSubviews: [titleLabel, valueLabel]) stack.axis = .vertical stack.spacing = 4 addSubview(stack) stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 2, left: 4, bottom: 2, right: 4)) } } } /// 线下收款明细单元格。 final class OfflineCollectionRecordCell: UITableViewCell { static let reuseIdentifier = "OfflineCollectionRecordCell" private let cardView = UIView() private let timeLabel = UILabel() private let methodLabel = UILabel() private let identifierLabel = UILabel() private let amountLabel = UILabel() private let statusLabel = UILabel() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) setupUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 用线下收款记录刷新单元格。 func apply(record: OfflineCollectionRecord) { timeLabel.text = OfflineCollectionDate.timeText(record.registeredAt) methodLabel.text = record.paymentMethod.displayName identifierLabel.text = record.id amountLabel.text = OfflineCollectionMoney.display(record.amountFen) statusLabel.text = " \(record.status.displayName) " let color: UIColor let background: UIColor switch record.status { case .pending: color = UIColor(hex: 0xD97706) background = UIColor(hex: 0xFFF7E6) case .settled: color = AppColor.success background = UIColor(hex: 0xECFDF3) case .overdue: color = AppColor.danger background = UIColor(hex: 0xFFF1F0) } statusLabel.textColor = color statusLabel.backgroundColor = background } private func setupUI() { selectionStyle = .none backgroundColor = .clear contentView.backgroundColor = .clear cardView.backgroundColor = .white cardView.layer.cornerRadius = AppRadius.lg timeLabel.font = .systemFont(ofSize: 15, weight: .semibold) timeLabel.textColor = AppColor.textPrimary methodLabel.font = .systemFont(ofSize: 14) methodLabel.textColor = AppColor.textSecondary identifierLabel.font = .systemFont(ofSize: 11) identifierLabel.textColor = AppColor.textTertiary amountLabel.font = .systemFont(ofSize: 19, weight: .bold) amountLabel.textColor = AppColor.textPrimary amountLabel.textAlignment = .right statusLabel.font = .systemFont(ofSize: 12, weight: .semibold) statusLabel.textAlignment = .center statusLabel.layer.cornerRadius = 11 statusLabel.clipsToBounds = true let leftStack = UIStackView(arrangedSubviews: [timeLabel, methodLabel, identifierLabel]) leftStack.axis = .vertical leftStack.spacing = 4 let rightStack = UIStackView(arrangedSubviews: [amountLabel, statusLabel]) rightStack.axis = .vertical rightStack.alignment = .trailing rightStack.spacing = 8 cardView.addSubview(leftStack) cardView.addSubview(rightStack) contentView.addSubview(cardView) cardView.snp.makeConstraints { make in make.top.bottom.equalToSuperview().inset(5) make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset) } leftStack.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview().inset(AppSpacing.md) make.trailing.lessThanOrEqualTo(rightStack.snp.leading).offset(-AppSpacing.sm) } rightStack.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(AppSpacing.md) make.centerY.equalToSuperview() } statusLabel.snp.makeConstraints { make in make.height.equalTo(22) } } } /// 线下收款补缴流水单元格。 final class OfflineSettlementBatchCell: UITableViewCell { static let reuseIdentifier = "OfflineSettlementBatchCell" private let cardView = UIView() private let identifierLabel = UILabel() private let amountLabel = UILabel() private let detailLabel = UILabel() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) setupUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 用成功补缴流水刷新单元格。 func apply(batch: OfflineSettlementBatch) { identifierLabel.text = batch.id amountLabel.text = OfflineCollectionMoney.display(batch.amountFen) detailLabel.text = "\(OfflineCollectionDate.dateTimeText(batch.paidAt)) · \(batch.payerName) · 关联 \(batch.recordCount) 笔" } private func setupUI() { selectionStyle = .none backgroundColor = .clear contentView.backgroundColor = .clear cardView.backgroundColor = UIColor(hex: 0xF4FBF7) cardView.layer.cornerRadius = AppRadius.lg cardView.layer.borderColor = UIColor(hex: 0xCDEED8).cgColor cardView.layer.borderWidth = 1 identifierLabel.font = .systemFont(ofSize: 13, weight: .semibold) identifierLabel.textColor = AppColor.success amountLabel.font = .systemFont(ofSize: 18, weight: .bold) amountLabel.textColor = AppColor.textPrimary amountLabel.textAlignment = .right detailLabel.font = .systemFont(ofSize: 12) detailLabel.textColor = AppColor.textSecondary detailLabel.numberOfLines = 0 let topRow = UIStackView(arrangedSubviews: [identifierLabel, amountLabel]) topRow.axis = .horizontal topRow.distribution = .fillEqually let stack = UIStackView(arrangedSubviews: [topRow, detailLabel]) stack.axis = .vertical stack.spacing = AppSpacing.sm cardView.addSubview(stack) contentView.addSubview(cardView) cardView.snp.makeConstraints { make in make.top.bottom.equalToSuperview().inset(5) make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset) } stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(AppSpacing.md) } } } /// 日清页在指定营业日无记录时展示的空状态单元格。 final class OfflineCollectionEmptyCell: UITableViewCell { static let reuseIdentifier = "OfflineCollectionEmptyCell" var onRegister: (() -> Void)? private let titleLabel = UILabel() private let detailLabel = UILabel() private let registerButton = UIButton(type: .system) override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) setupUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 更新空状态,仅今日允许前往登记。 func apply(isToday: Bool) { registerButton.isHidden = !isToday } override func prepareForReuse() { super.prepareForReuse() onRegister = nil } private func setupUI() { selectionStyle = .none backgroundColor = .clear contentView.backgroundColor = .clear titleLabel.text = "当日暂无线下收款记录" titleLabel.font = .systemFont(ofSize: 16, weight: .semibold) titleLabel.textColor = AppColor.textPrimary titleLabel.textAlignment = .center detailLabel.text = "完成线下收款后,请及时登记" detailLabel.font = .systemFont(ofSize: 13) detailLabel.textColor = AppColor.textSecondary detailLabel.textAlignment = .center registerButton.setTitle("去登记", for: .normal) registerButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold) registerButton.addTarget(self, action: #selector(registerTapped), for: .touchUpInside) let stack = UIStackView(arrangedSubviews: [titleLabel, detailLabel, registerButton]) stack.axis = .vertical stack.spacing = AppSpacing.sm stack.alignment = .center contentView.addSubview(stack) stack.snp.makeConstraints { make in make.top.equalToSuperview().inset(48) make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset) make.bottom.equalToSuperview().inset(32) } } @objc private func registerTapped() { onRegister?() } } private extension Collection { /// 安全按下标读取元素,超出范围时返回 nil。 subscript(safe index: Index) -> Element? { indices.contains(index) ? self[index] : nil } }