feat: 完成9.7线下收款登记与日清

This commit is contained in:
2026-08-26 09:53:48 +08:00
parent dca4bd5a20
commit 825a0448cc
24 changed files with 2663 additions and 6 deletions
@@ -0,0 +1,606 @@
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<Int, OfflineDailyItem>!
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 pendingStatusLabel = 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 = 82
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: 18, 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, badge: pendingStatusLabel)
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.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(24)
}
grid.snp.makeConstraints { make in
make.top.equalTo(summaryDateLabel.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(10)
make.bottom.equalToSuperview().inset(10)
}
summaryCard.snp.makeConstraints { make in make.height.equalTo(238) }
}
private func metric(title: String, value: UILabel, badge: UILabel? = nil) -> UIView {
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 13)
titleLabel.textColor = UIColor(hex: 0x7B8494)
titleLabel.textAlignment = .center
value.font = .systemFont(ofSize: 22, 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 = 8
if let badge {
badge.text = "未结清"
badge.font = .systemFont(ofSize: 12, weight: .medium)
badge.textColor = UIColor(hex: 0xFF7600)
badge.textAlignment = .center
badge.backgroundColor = UIColor(hex: 0xFFF0E2)
badge.layer.cornerRadius = 7
badge.clipsToBounds = true
badge.snp.makeConstraints { make in make.width.equalTo(72); make.height.equalTo(26) }
let badgeContainer = UIView()
badgeContainer.addSubview(badge)
badge.snp.makeConstraints { make in make.center.equalToSuperview() }
stack.addArrangedSubview(badgeContainer)
badgeContainer.snp.makeConstraints { make in make.height.equalTo(26) }
stack.spacing = 5
}
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)
pendingStatusLabel.isHidden = viewModel.summary.pendingCount == 0
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<Int, OfflineDailyItem>()
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<Int, OfflineDailyItem> {
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.backgroundColor = UIColor(hex: 0xF0F2F6)
idLabel.layer.cornerRadius = 5
idLabel.clipsToBounds = true
idLabel.textAlignment = .center
idLabel.adjustsFontSizeToFitWidth = true
idLabel.minimumScaleFactor = 0.7
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.addSubview(idLabel)
idLabel.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview()
make.trailing.lessThanOrEqualToSuperview()
}
idContainer.snp.makeConstraints { make in make.height.equalTo(20) }
let leftStack = UIStackView(arrangedSubviews: [titleLabel, idContainer])
leftStack.axis = .vertical
leftStack.spacing = 4
card.addSubview(iconView)
card.addSubview(leftStack)
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(78)
}
iconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
make.width.height.equalTo(38)
}
leftStack.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(10)
make.centerY.equalToSuperview()
make.width.equalTo(106)
}
amountLabel.snp.makeConstraints { make in
make.leading.greaterThanOrEqualTo(leftStack.snp.trailing).offset(4)
make.centerY.equalToSuperview()
make.width.equalTo(68)
}
statusLabel.snp.makeConstraints { make in
make.leading.equalTo(amountLabel.snp.trailing).offset(4)
make.centerY.equalToSuperview()
make.width.equalTo(50)
make.height.equalTo(27)
}
timeLabel.snp.makeConstraints { make in
make.leading.equalTo(statusLabel.snp.trailing).offset(2)
make.trailing.equalToSuperview().inset(10)
make.centerY.equalToSuperview()
make.width.greaterThanOrEqualTo(34)
}
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
}
}