import SnapKit import UIKit /// 日清列表条目。 private enum OfflineDailyItem: Hashable { case record(OfflineCollectionRecord) case empty(String) } /// 日清记录在白色列表卡中的圆角位置。 private enum OfflineRecordPosition { case single case first case middle case last } /// 线下收款日清页,按 9.7 视觉稿展示日历、2×2 汇总、明细和补缴操作。 @MainActor final class OfflineCollectionDailyViewController: BaseViewController { private let viewModel: OfflineCollectionDailyViewModel private let tableView = UITableView(frame: .zero, style: .plain) private var dataSource: UITableViewDiffableDataSource! private let headerContainer = UIView() private let headerStack = UIStackView() private let calendarView = OfflineCollectionCalendarView() private let summaryCard = UIView() private let summaryDateLabel = UILabel() private let totalValue = UILabel() private let countValue = UILabel() private let settledValue = UILabel() private let pendingValue = UILabel() private let statusButton = UIButton(type: .system) private let bottomContainer = UIView() private let settlementButton = OfflineCollectionGradientButton( startColor: UIColor(hex: 0xFF8B00), endColor: UIColor(hex: 0xFF7200) ) private var feedbackPresented = false private var isShowingGlobalLoading = false init( businessDate: String, context: OfflineCollectionContext = .current(), api: any OfflineCollectionServing = NetworkServices.shared.offlineCollectionAPI ) { viewModel = OfflineCollectionDailyViewModel(businessDate: businessDate, context: context, api: api) super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func setupNavigationBar() { title = "线下收款日清" } override func setupUI() { view.backgroundColor = UIColor(hex: 0xF7FAFF) configureTableView() configureHeader() configureBottomBar() view.addSubview(tableView) view.addSubview(bottomContainer) tableView.snp.makeConstraints { make in make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) make.bottom.equalTo(bottomContainer.snp.top) } bottomContainer.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } } override func bindActions() { calendarView.onDateSelected = { [weak self] date in guard let self else { return } Task { await self.viewModel.selectBusinessDate(date) } } calendarView.onModeChanged = { [weak self] in self?.resizeHeader() } viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) Task { await viewModel.refresh() } } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) setGlobalLoadingVisible(false) } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() resizeHeader() } private func configureTableView() { tableView.backgroundColor = .clear tableView.separatorStyle = .none tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 96 tableView.showsVerticalScrollIndicator = false tableView.contentInset.bottom = 12 tableView.register(OfflineCollectionRecordCell.self, forCellReuseIdentifier: OfflineCollectionRecordCell.reuseIdentifier) tableView.register(OfflineCollectionEmptyCell.self, forCellReuseIdentifier: OfflineCollectionEmptyCell.reuseIdentifier) dataSource = makeDataSource() } private func configureHeader() { headerStack.axis = .vertical headerStack.spacing = 16 headerContainer.addSubview(headerStack) headerStack.snp.makeConstraints { make in make.top.equalToSuperview().offset(12) make.bottom.equalToSuperview().inset(10) make.leading.trailing.equalToSuperview().inset(16) } headerStack.addArrangedSubview(calendarView) configureSummaryCard() headerStack.addArrangedSubview(summaryCard) let listTitle = UILabel() listTitle.text = "收款明细" listTitle.font = .systemFont(ofSize: 18, weight: .bold) listTitle.textColor = UIColor(hex: 0x081739) let listTitleContainer = UIView() listTitleContainer.addSubview(listTitle) listTitle.snp.makeConstraints { make in make.leading.equalToSuperview().offset(8) make.trailing.centerY.equalToSuperview() } listTitleContainer.snp.makeConstraints { make in make.height.equalTo(42) } headerStack.setCustomSpacing(18, after: summaryCard) headerStack.addArrangedSubview(listTitleContainer) statusButton.configuration = .plain() statusButton.configuration?.baseForegroundColor = AppColor.primary statusButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside) statusButton.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(44) } statusButton.isHidden = true headerStack.addArrangedSubview(statusButton) tableView.tableHeaderView = headerContainer } private func configureSummaryCard() { summaryCard.backgroundColor = .white summaryCard.layer.cornerRadius = 12 summaryCard.layer.shadowColor = UIColor(hex: 0x8FA0B8).cgColor summaryCard.layer.shadowOpacity = 0.12 summaryCard.layer.shadowRadius = 10 summaryCard.layer.shadowOffset = CGSize(width: 0, height: 4) summaryDateLabel.font = .systemFont(ofSize: 17, weight: .bold) summaryDateLabel.textColor = UIColor(hex: 0x081739) let topLeft = metric(title: "线下收款总额", value: totalValue) let topRight = metric(title: "登记笔数", value: countValue) let bottomLeft = metric(title: "已补缴", value: settledValue) let bottomRight = metric(title: "待补缴", value: pendingValue) let grid = UIView() [topLeft, topRight, bottomLeft, bottomRight].forEach(grid.addSubview) topLeft.snp.makeConstraints { make in make.top.leading.equalToSuperview() make.width.equalToSuperview().multipliedBy(0.5) make.height.equalToSuperview().multipliedBy(0.5) } topRight.snp.makeConstraints { make in make.top.trailing.equalToSuperview() make.width.height.equalTo(topLeft) } bottomLeft.snp.makeConstraints { make in make.bottom.leading.equalToSuperview() make.width.height.equalTo(topLeft) } bottomRight.snp.makeConstraints { make in make.bottom.trailing.equalToSuperview() make.width.height.equalTo(topLeft) } let horizontalDivider = UIView() let verticalDivider = UIView() [horizontalDivider, verticalDivider].forEach { $0.backgroundColor = UIColor(hex: 0xE7EBF1) grid.addSubview($0) } horizontalDivider.snp.makeConstraints { make in make.leading.trailing.centerY.equalToSuperview() make.height.equalTo(1) } verticalDivider.snp.makeConstraints { make in make.top.bottom.centerX.equalToSuperview() make.width.equalTo(1) } summaryCard.addSubview(summaryDateLabel) summaryCard.addSubview(grid) summaryDateLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(14) make.leading.trailing.equalToSuperview().inset(16) make.height.equalTo(22) } grid.snp.makeConstraints { make in make.top.equalTo(summaryDateLabel.snp.bottom).offset(6) make.leading.trailing.equalToSuperview().inset(10) make.bottom.equalToSuperview().inset(8) } summaryCard.snp.makeConstraints { make in make.height.equalTo(196) } } private func metric(title: String, value: UILabel) -> UIView { let titleLabel = UILabel() titleLabel.text = title titleLabel.font = .systemFont(ofSize: 12) titleLabel.textColor = UIColor(hex: 0x7B8494) titleLabel.textAlignment = .center value.font = .systemFont(ofSize: 20, weight: .bold) value.textColor = UIColor(hex: 0x081739) value.textAlignment = .center value.adjustsFontSizeToFitWidth = true value.minimumScaleFactor = 0.72 let stack = UIStackView(arrangedSubviews: [titleLabel, value]) stack.axis = .vertical stack.alignment = .fill stack.spacing = 6 let container = UIView() container.addSubview(stack) stack.snp.makeConstraints { make in make.center.equalToSuperview() make.leading.trailing.equalToSuperview().inset(12) } return container } private func configureBottomBar() { bottomContainer.backgroundColor = .white bottomContainer.layer.shadowColor = UIColor(hex: 0x8090A8).cgColor bottomContainer.layer.shadowOpacity = 0.12 bottomContainer.layer.shadowRadius = 12 bottomContainer.layer.shadowOffset = CGSize(width: 0, height: -3) settlementButton.setTitle("当日暂无待补缴", for: .normal) settlementButton.setTitleColor(.white, for: .normal) settlementButton.titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold) settlementButton.layer.cornerRadius = 10 settlementButton.clipsToBounds = true settlementButton.accessibilityIdentifier = "offlineCollection.settle" settlementButton.addTarget(self, action: #selector(settlementTapped), for: .touchUpInside) bottomContainer.addSubview(settlementButton) settlementButton.snp.makeConstraints { make in make.top.equalToSuperview().offset(14) make.leading.trailing.equalToSuperview().inset(16) make.height.equalTo(54) make.bottom.equalTo(bottomContainer.safeAreaLayoutGuide).inset(14) } } @MainActor private func applyState() { let processing = viewModel.settlementState == .processing setGlobalLoadingVisible(viewModel.isLoading || processing) calendarView.apply( selectedDate: viewModel.businessDate, maximumDate: viewModel.serverToday, pendingDates: viewModel.pendingDates ) summaryDateLabel.text = formattedSummaryDate() totalValue.text = OfflineCollectionMoney.display(viewModel.summary.totalAmountFen) countValue.text = "\(viewModel.summary.totalCount)笔" settledValue.text = OfflineCollectionMoney.display(viewModel.summary.settledAmountFen) pendingValue.text = OfflineCollectionMoney.display(viewModel.summary.pendingAmountFen) pendingValue.textColor = viewModel.summary.pendingCount > 0 ? UIColor(hex: 0xFF7600) : UIColor(hex: 0x081739) if let error = viewModel.errorMessage, !viewModel.isLoading { statusButton.isHidden = false statusButton.isUserInteractionEnabled = true statusButton.configuration?.title = "\(error) 点击重试" UIAccessibility.post(notification: .announcement, argument: error) } else { statusButton.isHidden = true } settlementButton.isEnabled = viewModel.canSettle && !processing if processing { settlementButton.setTitle("补缴中", for: .normal) } else if viewModel.summary.totalCount > 0 && viewModel.summary.pendingCount == 0 { settlementButton.setTitle("本日已全部补缴", for: .normal) } else if viewModel.summary.pendingCount == 0 { settlementButton.setTitle("当日暂无待补缴", for: .normal) } else { settlementButton.setTitle( "补缴本日全部 \(OfflineCollectionMoney.display(viewModel.summary.pendingAmountFen))", for: .normal ) } settlementButton.alpha = viewModel.canSettle || processing ? 1 : 0.45 applySnapshot() resizeHeader() presentFeedbackIfNeeded() } private func formattedSummaryDate() -> String { guard let date = OfflineCollectionDate.date(from: viewModel.businessDate) else { return viewModel.businessDate } let calendar = OfflineCollectionDate.calendar let text = "\(calendar.component(.month, from: date))月\(calendar.component(.day, from: date))日" return viewModel.businessDate == viewModel.serverToday ? "\(text) · 今日" : text } private func setGlobalLoadingVisible(_ visible: Bool) { guard visible != isShowingGlobalLoading else { return } isShowingGlobalLoading = visible visible ? showLoading() : hideLoading() } @MainActor private func applySnapshot() { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([0]) if viewModel.records.isEmpty { let text = viewModel.errorMessage == nil && !viewModel.isLoading ? "本日暂无线下收款记录" : "" snapshot.appendItems([.empty(text)]) } else { snapshot.appendItems(viewModel.records.map(OfflineDailyItem.record)) } dataSource.apply(snapshot, animatingDifferences: false) } private func makeDataSource() -> UITableViewDiffableDataSource { UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in switch item { case let .record(record): let cell = tableView.dequeueReusableCell( withIdentifier: OfflineCollectionRecordCell.reuseIdentifier, for: indexPath ) as! OfflineCollectionRecordCell let count = self?.viewModel.records.count ?? 1 let position: OfflineRecordPosition if count == 1 { position = .single } else if indexPath.row == 0 { position = .first } else if indexPath.row == count - 1 { position = .last } else { position = .middle } cell.apply(record, position: position) return cell case let .empty(text): let cell = tableView.dequeueReusableCell( withIdentifier: OfflineCollectionEmptyCell.reuseIdentifier, for: indexPath ) as! OfflineCollectionEmptyCell cell.apply(text) return cell } } } private func resizeHeader() { guard tableView.bounds.width > 0 else { return } headerContainer.frame.size.width = tableView.bounds.width headerContainer.setNeedsLayout() headerContainer.layoutIfNeeded() let height = headerContainer.systemLayoutSizeFitting( CGSize(width: tableView.bounds.width, height: UIView.layoutFittingCompressedSize.height), withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel ).height guard abs(headerContainer.frame.height - height) > 0.5 else { return } headerContainer.frame.size.height = height tableView.tableHeaderView = headerContainer } @MainActor private func presentFeedbackIfNeeded() { guard !feedbackPresented else { return } switch viewModel.settlementState { case .idle, .processing: return case let .success(result): feedbackPresented = true let alert = UIAlertController( title: "补缴成功", message: "营业日:\(result.date)\n共结清 \(result.updatedCount) 笔", preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "完成", style: .default) { [weak self] _ in self?.finishFeedback() }) present(alert, animated: true) case let .failed(message, canRetry): feedbackPresented = true let alert = UIAlertController(title: "补缴失败", message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "暂不处理", style: .cancel) { [weak self] _ in self?.viewModel.cancelPreparedSettlement() self?.finishFeedback() }) if canRetry { alert.addAction(UIAlertAction(title: "重新补缴", style: .default) { [weak self] _ in guard let self else { return } self.feedbackPresented = false Task { await self.viewModel.confirmSettlement() } }) } present(alert, animated: true) } } private func finishFeedback() { feedbackPresented = false viewModel.clearSettlementFeedback() } @objc private func retryTapped() { Task { await viewModel.refresh() } } @objc private func settlementTapped() { do { let request = try viewModel.prepareSettlement() let message = "营业日:\(request.businessDate)\n待补缴记录:\(request.pendingCount) 笔\n本次补缴金额:\(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: "确认补缴", style: .default) { [weak self] _ in guard let self else { return } Task { await self.viewModel.confirmSettlement() } }) present(alert, animated: true) } catch { showToast(error.localizedDescription) } } } /// 日清页的一笔收款记录行,纯展示支付方式、编号、金额、状态与时间。 private final class OfflineCollectionRecordCell: UITableViewCell { static let reuseIdentifier = "OfflineCollectionRecordCell" private let card = UIView() private let iconView = UIImageView() private let titleLabel = UILabel() private let idLabel = UILabel() private let amountLabel = UILabel() private let statusLabel = UILabel() private let timeLabel = UILabel() private let separator = UIView() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear contentView.backgroundColor = .clear card.backgroundColor = .white iconView.contentMode = .scaleAspectFit titleLabel.font = .systemFont(ofSize: 15, weight: .semibold) titleLabel.textColor = UIColor(hex: 0x081739) idLabel.font = .systemFont(ofSize: 9.5, weight: .medium) idLabel.textColor = UIColor(hex: 0x6F7B8F) idLabel.textAlignment = .left idLabel.numberOfLines = 0 idLabel.lineBreakMode = .byCharWrapping idLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) idLabel.setContentCompressionResistancePriority(.required, for: .vertical) amountLabel.font = .systemFont(ofSize: 16, weight: .bold) amountLabel.textColor = UIColor(hex: 0x081739) amountLabel.textAlignment = .right amountLabel.adjustsFontSizeToFitWidth = true amountLabel.minimumScaleFactor = 0.72 statusLabel.font = .systemFont(ofSize: 11, weight: .medium) statusLabel.textAlignment = .center statusLabel.layer.cornerRadius = 7 statusLabel.clipsToBounds = true timeLabel.font = .systemFont(ofSize: 12) timeLabel.textColor = UIColor(hex: 0x7B8494) timeLabel.textAlignment = .right separator.backgroundColor = UIColor(hex: 0xE7EBF1) let idContainer = UIView() idContainer.backgroundColor = UIColor(hex: 0xF0F2F6) idContainer.layer.cornerRadius = 5 idContainer.clipsToBounds = true idContainer.addSubview(idLabel) idLabel.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 3, left: 6, bottom: 3, right: 6)) } card.addSubview(iconView) card.addSubview(titleLabel) card.addSubview(idContainer) card.addSubview(amountLabel) card.addSubview(statusLabel) card.addSubview(timeLabel) card.addSubview(separator) contentView.addSubview(card) card.snp.makeConstraints { make in make.top.bottom.equalToSuperview() make.leading.trailing.equalToSuperview().inset(16) make.height.greaterThanOrEqualTo(90) } iconView.snp.makeConstraints { make in make.leading.equalToSuperview().offset(12) make.top.equalToSuperview().offset(10) make.width.height.equalTo(38) } titleLabel.snp.makeConstraints { make in make.leading.equalTo(iconView.snp.trailing).offset(10) make.centerY.equalTo(iconView) } amountLabel.snp.makeConstraints { make in make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(4) make.centerY.equalTo(iconView) make.width.equalTo(68) } statusLabel.snp.makeConstraints { make in make.leading.equalTo(amountLabel.snp.trailing).offset(4) make.trailing.equalToSuperview().inset(12) make.centerY.equalTo(iconView) make.width.equalTo(50) make.height.equalTo(27) } timeLabel.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(12) make.centerY.equalTo(idContainer) make.width.equalTo(40) } idContainer.snp.makeConstraints { make in make.leading.equalTo(iconView) make.top.equalTo(iconView.snp.bottom).offset(6) make.trailing.lessThanOrEqualTo(timeLabel.snp.leading).offset(-8) make.bottom.equalToSuperview().inset(10) } separator.snp.makeConstraints { make in make.leading.equalToSuperview().offset(12) make.trailing.equalToSuperview().inset(8) make.bottom.equalToSuperview() make.height.equalTo(1 / UIScreen.main.scale) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(_ record: OfflineCollectionRecord, position: OfflineRecordPosition) { iconView.image = UIImage(named: record.paymentMethod.assetName) titleLabel.text = record.paymentMethod == .wechat ? "微信收款" : record.paymentMethod.displayName idLabel.text = "编号 \(record.collectNo)" amountLabel.text = OfflineCollectionMoney.display(record.amountFen) let settled = record.status == .settled statusLabel.text = settled ? "已补缴" : "未补缴" statusLabel.textColor = settled ? UIColor(hex: 0x16A34A) : UIColor(hex: 0xFF7600) statusLabel.backgroundColor = settled ? UIColor(hex: 0xEAF8EF) : UIColor(hex: 0xFFF0E2) timeLabel.text = record.timeText separator.isHidden = position == .single || position == .last card.layer.cornerRadius = 12 switch position { case .single: card.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] case .first: card.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] case .middle: card.layer.maskedCorners = [] case .last: card.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] } accessibilityLabel = "\(titleLabel.text ?? ""),编号 \(record.collectNo),\(amountLabel.text ?? ""),\(statusLabel.text ?? ""),\(record.timeText)" } } /// 日清页空数据占位。 private final class OfflineCollectionEmptyCell: UITableViewCell { static let reuseIdentifier = "OfflineCollectionEmptyCell" private let label = UILabel() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear label.font = .systemFont(ofSize: 14) label.textColor = UIColor(hex: 0x7B8494) label.textAlignment = .center contentView.addSubview(label) label.snp.makeConstraints { make in make.edges.equalToSuperview().inset(36) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(_ text: String) { label.text = text } }