新增线下收款记录和 ai 修图优化
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
//
|
||||
// OfflineCollectionRegistrationViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 线下收款登记页,仅收集金额和线下收款方式。
|
||||
final class OfflineCollectionRegistrationViewController: BaseViewController {
|
||||
private let viewModel: OfflineCollectionRegistrationViewModel
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack = UIStackView()
|
||||
private let amountCard = UIView()
|
||||
private let amountField = UITextField()
|
||||
private let methodCard = UIView()
|
||||
private let methodStack = UIStackView()
|
||||
private var methodButtons: [OfflineCollectionPaymentMethod: OfflinePaymentMethodButton] = [:]
|
||||
private let explanationCard = UIView()
|
||||
private let contextLabel = UILabel()
|
||||
private let bottomContainer = UIView()
|
||||
private let submitButton = UIButton(type: .system)
|
||||
private var hasFocusedAmountField = false
|
||||
|
||||
/// 使用指定的收款上下文与 Mock 服务创建登记页。
|
||||
init(
|
||||
context: OfflineCollectionContext = .current(),
|
||||
service: OfflineCollectionMockService = .shared
|
||||
) {
|
||||
viewModel = OfflineCollectionRegistrationViewModel(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 = "线下收款登记"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = AppColor.pageBackground
|
||||
scrollView.keyboardDismissMode = .interactive
|
||||
scrollView.alwaysBounceVertical = true
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = AppSpacing.md
|
||||
|
||||
configureCard(amountCard)
|
||||
let amountTitleLabel = makeTitleLabel("收款金额")
|
||||
let currencyLabel = UILabel()
|
||||
currencyLabel.text = "¥"
|
||||
currencyLabel.font = .systemFont(ofSize: 34, weight: .semibold)
|
||||
currencyLabel.textColor = AppColor.textPrimary
|
||||
|
||||
amountField.placeholder = "0.00"
|
||||
amountField.font = .systemFont(ofSize: 38, weight: .bold)
|
||||
amountField.textColor = AppColor.textPrimary
|
||||
amountField.keyboardType = .decimalPad
|
||||
amountField.clearButtonMode = .whileEditing
|
||||
amountField.delegate = self
|
||||
amountField.accessibilityLabel = "收款金额"
|
||||
|
||||
let amountHelperLabel = UILabel()
|
||||
amountHelperLabel.text = "单笔金额 0.01~99,999.99 元,最多两位小数"
|
||||
amountHelperLabel.font = .systemFont(ofSize: 12)
|
||||
amountHelperLabel.textColor = AppColor.textTertiary
|
||||
|
||||
let amountRow = UIStackView(arrangedSubviews: [currencyLabel, amountField])
|
||||
amountRow.axis = .horizontal
|
||||
amountRow.alignment = .center
|
||||
amountRow.spacing = AppSpacing.sm
|
||||
let amountStack = UIStackView(arrangedSubviews: [amountTitleLabel, amountRow, amountHelperLabel])
|
||||
amountStack.axis = .vertical
|
||||
amountStack.spacing = AppSpacing.sm
|
||||
amountCard.addSubview(amountStack)
|
||||
amountStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppSpacing.lg)
|
||||
}
|
||||
amountField.snp.makeConstraints { make in
|
||||
make.height.equalTo(58)
|
||||
}
|
||||
|
||||
configureCard(methodCard)
|
||||
let methodTitleLabel = makeTitleLabel("收款方式")
|
||||
methodStack.axis = .horizontal
|
||||
methodStack.distribution = .fillEqually
|
||||
methodStack.spacing = AppSpacing.sm
|
||||
for method in OfflineCollectionPaymentMethod.allCases {
|
||||
let button = OfflinePaymentMethodButton(method: method)
|
||||
button.addTarget(self, action: #selector(methodTapped(_:)), for: .touchUpInside)
|
||||
methodButtons[method] = button
|
||||
methodStack.addArrangedSubview(button)
|
||||
}
|
||||
let methodContentStack = UIStackView(arrangedSubviews: [methodTitleLabel, methodStack])
|
||||
methodContentStack.axis = .vertical
|
||||
methodContentStack.spacing = AppSpacing.md
|
||||
methodCard.addSubview(methodContentStack)
|
||||
methodContentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppSpacing.lg)
|
||||
}
|
||||
methodStack.snp.makeConstraints { make in
|
||||
make.height.equalTo(64)
|
||||
}
|
||||
|
||||
configureCard(explanationCard)
|
||||
let explanationIcon = UIImageView(image: UIImage(systemName: "info.circle.fill"))
|
||||
explanationIcon.tintColor = AppColor.primary
|
||||
explanationIcon.contentMode = .scaleAspectFit
|
||||
let explanationLabel = UILabel()
|
||||
explanationLabel.text = "登记后将计入今日待补缴,不生成订单。"
|
||||
explanationLabel.font = .systemFont(ofSize: 14)
|
||||
explanationLabel.textColor = AppColor.textSecondary
|
||||
explanationLabel.numberOfLines = 0
|
||||
explanationCard.addSubview(explanationIcon)
|
||||
explanationCard.addSubview(explanationLabel)
|
||||
explanationIcon.snp.makeConstraints { make in
|
||||
make.leading.top.equalToSuperview().inset(AppSpacing.md)
|
||||
make.width.height.equalTo(20)
|
||||
}
|
||||
explanationLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(explanationIcon.snp.trailing).offset(AppSpacing.sm)
|
||||
make.trailing.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||
make.top.equalToSuperview().inset(AppSpacing.md)
|
||||
}
|
||||
|
||||
contextLabel.text = "当前:\(viewModel.context.collectorName) · \(viewModel.context.storeName) · \(viewModel.context.scenicName)"
|
||||
contextLabel.font = .systemFont(ofSize: 12)
|
||||
contextLabel.textColor = AppColor.textTertiary
|
||||
contextLabel.numberOfLines = 0
|
||||
contextLabel.textAlignment = .center
|
||||
|
||||
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 submitConfiguration = UIButton.Configuration.filled()
|
||||
submitConfiguration.title = "确认登记"
|
||||
submitConfiguration.baseBackgroundColor = AppColor.primary
|
||||
submitConfiguration.baseForegroundColor = .white
|
||||
submitConfiguration.cornerStyle = .medium
|
||||
submitConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
||||
var outgoing = incoming
|
||||
outgoing.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
return outgoing
|
||||
}
|
||||
submitButton.configuration = submitConfiguration
|
||||
submitButton.accessibilityIdentifier = "offlineCollection.submit"
|
||||
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(contentStack)
|
||||
[amountCard, methodCard, explanationCard, contextLabel].forEach(contentStack.addArrangedSubview)
|
||||
view.addSubview(bottomContainer)
|
||||
bottomContainer.addSubview(submitButton)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
bottomContainer.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
|
||||
}
|
||||
submitButton.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().inset(AppSpacing.md)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||
make.height.equalTo(50)
|
||||
make.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||
}
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||
make.bottom.equalTo(bottomContainer.snp.top)
|
||||
}
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
amountField.addTarget(self, action: #selector(amountChanged), for: .editingChanged)
|
||||
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
|
||||
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
viewModel.onRegistrationSuccess = { [weak self] receipt in
|
||||
Task { @MainActor in self?.showRegistrationSuccess(receipt) }
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
applyViewModel()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
guard !hasFocusedAmountField else { return }
|
||||
hasFocusedAmountField = true
|
||||
amountField.becomeFirstResponder()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyViewModel() {
|
||||
if amountField.text != viewModel.amountText {
|
||||
amountField.text = viewModel.amountText
|
||||
}
|
||||
methodButtons.forEach { method, button in
|
||||
button.setSelected(method == viewModel.paymentMethod)
|
||||
}
|
||||
submitButton.isEnabled = viewModel.canSubmit
|
||||
submitButton.configuration?.showsActivityIndicator = viewModel.isSubmitting
|
||||
submitButton.configuration?.title = viewModel.isSubmitting ? "登记中" : "确认登记"
|
||||
submitButton.alpha = submitButton.isEnabled || viewModel.isSubmitting ? 1 : 0.45
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func showRegistrationSuccess(_ receipt: OfflineCollectionRegistrationReceipt) {
|
||||
amountField.resignFirstResponder()
|
||||
let message = """
|
||||
本次登记 \(OfflineCollectionMoney.display(receipt.record.amountFen))
|
||||
今日累计线下收款 \(OfflineCollectionMoney.display(receipt.summary.totalAmountFen))
|
||||
今日待补缴 \(OfflineCollectionMoney.display(receipt.summary.pendingAmountFen))
|
||||
"""
|
||||
let alert = UIAlertController(title: "登记成功", message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "继续登记", style: .default) { [weak self] _ in
|
||||
self?.viewModel.startAnotherRegistration()
|
||||
self?.amountField.becomeFirstResponder()
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "查看今日明细", style: .default) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
let controller = OfflineCollectionDailyViewController(
|
||||
businessDate: receipt.summary.businessDate,
|
||||
context: self.viewModel.context,
|
||||
service: self.viewModel.service
|
||||
)
|
||||
self.navigationController?.pushViewController(controller, animated: true)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func makeTitleLabel(_ text: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = text
|
||||
label.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
label.textColor = AppColor.textPrimary
|
||||
return label
|
||||
}
|
||||
|
||||
private func configureCard(_ card: UIView) {
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = AppRadius.lg
|
||||
}
|
||||
|
||||
@objc private func amountChanged() {
|
||||
viewModel.updateAmount(amountField.text ?? "")
|
||||
}
|
||||
|
||||
@objc private func methodTapped(_ sender: OfflinePaymentMethodButton) {
|
||||
viewModel.selectPaymentMethod(sender.method)
|
||||
}
|
||||
|
||||
@objc private func submitTapped() {
|
||||
Task { await viewModel.submit() }
|
||||
}
|
||||
}
|
||||
|
||||
extension OfflineCollectionRegistrationViewController: UITextFieldDelegate {
|
||||
func textField(
|
||||
_ textField: UITextField,
|
||||
shouldChangeCharactersIn range: NSRange,
|
||||
replacementString string: String
|
||||
) -> Bool {
|
||||
guard let current = textField.text,
|
||||
let textRange = Range(range, in: current) else { return false }
|
||||
let next = current.replacingCharacters(in: textRange, with: string)
|
||||
return OfflineCollectionMoney.acceptsEditingText(next)
|
||||
}
|
||||
}
|
||||
|
||||
/// 登记页的收款方式单选按钮。
|
||||
final class OfflinePaymentMethodButton: UIControl {
|
||||
let method: OfflineCollectionPaymentMethod
|
||||
|
||||
private let iconView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
|
||||
/// 使用指定收款方式创建单选按钮。
|
||||
init(method: OfflineCollectionPaymentMethod) {
|
||||
self.method = method
|
||||
super.init(frame: .zero)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 更新选中态视觉。
|
||||
func setSelected(_ selected: Bool) {
|
||||
isSelected = selected
|
||||
backgroundColor = selected ? UIColor(hex: 0xEEF5FF) : AppColor.inputBackground
|
||||
layer.borderColor = (selected ? AppColor.primary : UIColor.clear).cgColor
|
||||
layer.borderWidth = selected ? 1.5 : 0
|
||||
titleLabel.textColor = selected ? AppColor.primary : AppColor.textSecondary
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
layer.cornerRadius = AppRadius.md
|
||||
clipsToBounds = true
|
||||
accessibilityLabel = method.displayName
|
||||
|
||||
iconView.image = UIImage(named: method.assetName)?.withRenderingMode(.alwaysOriginal)
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
titleLabel.text = method.displayName
|
||||
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [iconView, titleLabel])
|
||||
stack.axis = .vertical
|
||||
stack.alignment = .center
|
||||
stack.spacing = 5
|
||||
stack.isUserInteractionEnabled = false
|
||||
addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(22)
|
||||
}
|
||||
setSelected(false)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user