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,252 @@
import SnapKit
import UIKit
/// 日清页顶部可横滑的周/月日历组件。
@MainActor
final class OfflineCollectionCalendarView: UIView {
var onDateSelected: ((String) -> Void)?
var onModeChanged: (() -> Void)?
private let titleLabel = UILabel()
private let toggleButton = UIButton(type: .system)
private let nextButton = UIButton(type: .system)
private let rowsStack = UIStackView()
private var rowStacks: [UIStackView] = []
private var dayButtons: [OfflineCollectionDayButton] = []
private var pendingDates: Set<String> = []
private var state = OfflineCollectionCalendarState(selectedDate: Date(), maximumDate: Date())
private let calendar = OfflineCollectionDate.calendar
override var intrinsicContentSize: CGSize {
CGSize(width: UIView.noIntrinsicMetric, height: state.mode == .week ? 160 : 378)
}
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
/// 使用选中日、服务端今日和待补缴日期刷新组件。
func apply(selectedDate: String, maximumDate: String, pendingDates: Set<String>) {
guard let selected = OfflineCollectionDate.date(from: selectedDate),
let maximum = OfflineCollectionDate.date(from: maximumDate) else { return }
self.pendingDates = pendingDates
state = OfflineCollectionCalendarState(
selectedDate: selected,
maximumDate: maximum,
mode: state.mode,
calendar: calendar
)
reloadDates()
}
private func setupUI() {
backgroundColor = .white
layer.cornerRadius = AppRadius.lg
layer.shadowColor = UIColor(hex: 0x8FA0B8).cgColor
layer.shadowOpacity = 0.12
layer.shadowRadius = 10
layer.shadowOffset = CGSize(width: 0, height: 4)
titleLabel.font = .systemFont(ofSize: 20, weight: .bold)
titleLabel.textColor = UIColor(hex: 0x081739)
toggleButton.configuration = .plain()
toggleButton.configuration?.baseForegroundColor = UIColor(hex: 0x081739)
toggleButton.configuration?.imagePlacement = .trailing
toggleButton.configuration?.imagePadding = 8
toggleButton.configuration?.contentInsets = NSDirectionalEdgeInsets(top: 7, leading: 14, bottom: 7, trailing: 12)
toggleButton.layer.cornerRadius = 9
toggleButton.layer.borderWidth = 1
toggleButton.layer.borderColor = UIColor(hex: 0xE2E7EF).cgColor
toggleButton.addTarget(self, action: #selector(toggleMode), for: .touchUpInside)
toggleButton.accessibilityIdentifier = "offlineCollection.calendar.toggle"
nextButton.setImage(UIImage(systemName: "chevron.right"), for: .normal)
nextButton.tintColor = UIColor(hex: 0x081739)
nextButton.accessibilityLabel = "下一页"
nextButton.addTarget(self, action: #selector(nextPage), for: .touchUpInside)
nextButton.snp.makeConstraints { make in make.width.height.equalTo(44) }
let spacer = UIView()
let header = UIStackView(arrangedSubviews: [titleLabel, spacer, toggleButton, nextButton])
header.axis = .horizontal
header.alignment = .center
header.spacing = 8
let weekdayStack = UIStackView()
weekdayStack.axis = .horizontal
weekdayStack.distribution = .fillEqually
["一", "二", "三", "四", "五", "六", "日"].forEach { value in
let label = UILabel()
label.text = value
label.font = .systemFont(ofSize: 13, weight: .medium)
label.textColor = UIColor(hex: 0x657084)
label.textAlignment = .center
weekdayStack.addArrangedSubview(label)
}
rowsStack.axis = .vertical
rowsStack.distribution = .fillEqually
rowsStack.spacing = 1
for _ in 0 ..< 6 {
let row = UIStackView()
row.axis = .horizontal
row.distribution = .fillEqually
for _ in 0 ..< 7 {
let button = OfflineCollectionDayButton()
button.addTarget(self, action: #selector(dayTapped(_:)), for: .touchUpInside)
dayButtons.append(button)
row.addArrangedSubview(button)
}
rowStacks.append(row)
rowsStack.addArrangedSubview(row)
}
addSubview(header)
addSubview(weekdayStack)
addSubview(rowsStack)
header.snp.makeConstraints { make in
make.top.equalToSuperview().offset(14)
make.leading.equalToSuperview().offset(16)
make.trailing.equalToSuperview().inset(10)
make.height.equalTo(44)
}
weekdayStack.snp.makeConstraints { make in
make.top.equalTo(header.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(10)
make.height.equalTo(24)
}
rowsStack.snp.makeConstraints { make in
make.top.equalTo(weekdayStack.snp.bottom).offset(4)
make.leading.trailing.equalToSuperview().inset(10)
make.bottom.equalToSuperview().inset(12)
}
let left = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:)))
left.direction = .left
let right = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:)))
right.direction = .right
addGestureRecognizer(left)
addGestureRecognizer(right)
reloadDates()
}
private func reloadDates() {
titleLabel.text = state.monthTitle
let isMonth = state.mode == .month
toggleButton.configuration?.title = isMonth ? "周" : "月"
toggleButton.configuration?.image = UIImage(systemName: isMonth ? "chevron.up" : "chevron.down")
rowStacks.enumerated().forEach { $0.element.isHidden = !isMonth && $0.offset > 0 }
let dates = isMonth ? state.monthDates : state.weekDates
let selectedMonth = calendar.component(.month, from: state.selectedDate)
for (index, button) in dayButtons.enumerated() {
guard index < dates.count else {
button.isHidden = true
continue
}
let date = dates[index]
let key = OfflineCollectionDate.businessDate(for: date, calendar: calendar)
button.isHidden = false
button.apply(
date: date,
key: key,
selected: calendar.isDate(date, inSameDayAs: state.selectedDate),
pending: pendingDates.contains(key),
today: calendar.isDate(date, inSameDayAs: state.maximumDate),
enabled: date <= state.maximumDate,
inCurrentMonth: !isMonth || calendar.component(.month, from: date) == selectedMonth,
calendar: calendar
)
}
invalidateIntrinsicContentSize()
}
@objc private func toggleMode() {
state.toggleMode()
let animations = { [weak self] in
self?.reloadDates()
self?.superview?.layoutIfNeeded()
}
if UIAccessibility.isReduceMotionEnabled { animations() } else {
UIView.animate(withDuration: 0.2, animations: animations)
}
onModeChanged?()
}
@objc private func dayTapped(_ sender: OfflineCollectionDayButton) {
guard let date = sender.date, state.select(date) else { return }
reloadDates()
onDateSelected?(OfflineCollectionDate.businessDate(for: date, calendar: calendar))
}
@objc private func swiped(_ gesture: UISwipeGestureRecognizer) {
let oldDate = state.selectedDate
let offset = gesture.direction == .left ? 1 : -1
let date = state.movePage(offset)
guard !calendar.isDate(oldDate, inSameDayAs: date) else { return }
let transition: UIView.AnimationOptions = gesture.direction == .left ? .transitionCrossDissolve : .transitionCrossDissolve
if UIAccessibility.isReduceMotionEnabled { reloadDates() } else {
UIView.transition(with: rowsStack, duration: 0.18, options: transition) { [weak self] in self?.reloadDates() }
}
onDateSelected?(OfflineCollectionDate.businessDate(for: date, calendar: calendar))
}
@objc private func nextPage() {
let oldDate = state.selectedDate
let date = state.movePage(1)
guard !calendar.isDate(oldDate, inSameDayAs: date) else { return }
reloadDates()
onDateSelected?(OfflineCollectionDate.businessDate(for: date, calendar: calendar))
}
}
/// 日历中的单个日期按钮,同时表达选中、今日和待补缴状态。
private final class OfflineCollectionDayButton: UIControl {
private let dayLabel = UILabel()
private let pendingDot = UIView()
private(set) var date: Date?
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 10
dayLabel.font = .systemFont(ofSize: 17, weight: .medium)
dayLabel.textAlignment = .center
pendingDot.layer.cornerRadius = 2.5
addSubview(dayLabel)
addSubview(pendingDot)
dayLabel.snp.makeConstraints { make in make.centerX.equalToSuperview(); make.centerY.equalToSuperview().offset(-2) }
pendingDot.snp.makeConstraints { make in make.top.equalTo(dayLabel.snp.bottom).offset(2); make.centerX.equalToSuperview(); make.width.height.equalTo(5) }
snp.makeConstraints { make in make.height.greaterThanOrEqualTo(44) }
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func apply(
date: Date,
key: String,
selected: Bool,
pending: Bool,
today: Bool,
enabled: Bool,
inCurrentMonth: Bool,
calendar: Calendar
) {
self.date = date
isEnabled = enabled
dayLabel.text = String(calendar.component(.day, from: date))
pendingDot.isHidden = !pending
pendingDot.backgroundColor = selected ? .white : AppColor.danger
backgroundColor = selected ? AppColor.primary : (pending ? UIColor(hex: 0xFFF1F0) : .clear)
layer.borderWidth = pending && selected ? 2 : (today && !selected ? 1 : 0)
layer.borderColor = (pending && selected ? AppColor.danger : AppColor.primary).cgColor
dayLabel.textColor = selected ? .white : (enabled ? (inCurrentMonth ? AppColor.textPrimary : AppColor.textTertiary) : AppColor.textTertiary)
alpha = enabled ? 1 : 0.35
accessibilityLabel = "\(key)\(today ? ",今天" : "")\(pending ? ",待补缴" : "")\(selected ? ",已选中" : "")"
accessibilityTraits = selected ? [.button, .selected] : .button
}
}
@@ -0,0 +1,170 @@
import SnapKit
import UIKit
/// 收款页中的线下收款入口、今日汇总和逾期提醒区域。
@MainActor
final class OfflineCollectionHomeView: UIView {
var onRegister: (() -> Void)?
var onOpenToday: (() -> Void)?
var onOpenOverdue: (() -> Void)?
var onRetry: (() -> Void)?
private let stack = UIStackView()
private let statusButton = UIButton(type: .system)
private let overdueCard = OfflineCollectionHomeCard()
private let registerCard = OfflineCollectionHomeCard()
private let todayCard = OfflineCollectionHomeCard()
override init(frame: CGRect) {
super.init(frame: frame)
stack.axis = .vertical
stack.spacing = AppSpacing.md
let title = UILabel()
title.text = "线下收款"
title.font = .systemFont(ofSize: 16, weight: .semibold)
title.textColor = AppColor.textPrimary
stack.addArrangedSubview(title)
stack.addArrangedSubview(statusButton)
stack.addArrangedSubview(overdueCard)
stack.addArrangedSubview(registerCard)
stack.addArrangedSubview(todayCard)
addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview() }
title.snp.makeConstraints { make in make.height.equalTo(24) }
statusButton.configuration = .plain()
statusButton.configuration?.baseForegroundColor = AppColor.primary
statusButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
statusButton.isHidden = true
registerCard.apply(
title: "线下收款登记",
detail: "线下收款后,请及时登记并在当日完成补缴",
action: "去登记",
image: "plus.circle.fill",
tint: AppColor.primary,
background: .white
)
registerCard.addTarget(self, action: #selector(registerTapped), for: .touchUpInside)
todayCard.addTarget(self, action: #selector(todayTapped), for: .touchUpInside)
overdueCard.addTarget(self, action: #selector(overdueTapped), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
/// 根据真实统计接口状态刷新区域。
func apply(
today: OfflineDailySummary,
overdueDayCount: Int,
overdueRecordCount: Int,
overdueAmountFen: Int,
errorMessage: String?
) {
if let errorMessage {
statusButton.configuration?.title = "\(errorMessage) 点击重试"
statusButton.configuration?.showsActivityIndicator = false
statusButton.isUserInteractionEnabled = true
statusButton.isHidden = false
statusButton.accessibilityTraits.insert(.button)
} else {
statusButton.isHidden = true
}
let hasOverdue = overdueDayCount > 0
overdueCard.isHidden = !hasOverdue
if hasOverdue {
overdueCard.apply(
title: "存在逾期未补缴",
detail: "\(overdueDayCount) 个营业日,共 \(overdueRecordCount) 笔,待补缴 \(OfflineCollectionMoney.display(overdueAmountFen))",
action: "立即处理",
image: "exclamationmark.circle.fill",
tint: AppColor.danger,
background: UIColor(hex: 0xFFF1F0)
)
}
let settled = today.totalCount > 0 && today.pendingCount == 0
todayCard.apply(
title: "今日待补缴 \(OfflineCollectionMoney.display(today.pendingAmountFen))",
detail: "\(today.pendingCount) 笔 · 今日已登记 \(OfflineCollectionMoney.display(today.totalAmountFen))\(settled ? " · 已结清" : "")",
action: today.pendingCount > 0 ? "去补缴" : "查看明细",
image: "calendar",
tint: today.pendingCount > 0 ? UIColor(hex: 0xD97706) : AppColor.primary,
background: .white
)
}
@objc private func retryTapped() { onRetry?() }
@objc private func registerTapped() { onRegister?() }
@objc private func todayTapped() { onOpenToday?() }
@objc private func overdueTapped() { onOpenOverdue?() }
}
/// 线下收款首页区域的统一可点击卡片。
private final class OfflineCollectionHomeCard: UIControl {
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let detailLabel = UILabel()
private let actionLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = AppRadius.lg
clipsToBounds = true
isAccessibilityElement = true
iconView.contentMode = .scaleAspectFit
titleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
detailLabel.font = .systemFont(ofSize: 13)
detailLabel.textColor = AppColor.textSecondary
detailLabel.numberOfLines = 0
actionLabel.font = .systemFont(ofSize: 13, weight: .semibold)
actionLabel.textColor = AppColor.primary
actionLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
let textStack = UIStackView(arrangedSubviews: [titleLabel, detailLabel])
textStack.axis = .vertical
textStack.spacing = 5
[iconView, titleLabel, detailLabel, actionLabel, textStack].forEach {
$0.isUserInteractionEnabled = false
}
addSubview(iconView)
addSubview(textStack)
addSubview(actionLabel)
iconView.snp.makeConstraints { make in make.leading.equalToSuperview().inset(AppSpacing.md); make.centerY.equalToSuperview(); make.width.height.equalTo(30) }
textStack.snp.makeConstraints { make in make.leading.equalTo(iconView.snp.trailing).offset(AppSpacing.sm); make.top.bottom.equalToSuperview().inset(AppSpacing.md); make.trailing.lessThanOrEqualTo(actionLabel.snp.leading).offset(-AppSpacing.sm) }
actionLabel.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(AppSpacing.md); make.centerY.equalToSuperview() }
snp.makeConstraints { make in make.height.greaterThanOrEqualTo(86) }
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard isEnabled,
isUserInteractionEnabled,
!isHidden,
alpha > 0.01,
self.point(inside: point, with: event) else { return nil }
return self
}
override var isHighlighted: Bool {
didSet {
UIView.animate(withDuration: 0.12) {
self.alpha = self.isHighlighted ? 0.78 : 1
}
}
}
func apply(title: String, detail: String, action: String, image: String, tint: UIColor, background: UIColor) {
titleLabel.text = title
detailLabel.text = detail
actionLabel.text = "\(action) ›"
iconView.image = UIImage(systemName: image)
iconView.tintColor = tint
backgroundColor = background
accessibilityLabel = "\(title),\(detail),\(action)"
accessibilityTraits = .button
}
}