import SnapKit import UIKit /// 线下收款登记页,按 9.7 视觉稿展示金额、支付方式和登记说明。 @MainActor 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 = OfflineCollectionGradientButton( startColor: UIColor(hex: 0x087BFF), endColor: UIColor(hex: 0x0067F4) ) private var hasFocusedAmountField = false private var isShowingGlobalLoading = false init( context: OfflineCollectionContext = .current(), api: any OfflineCollectionServing = NetworkServices.shared.offlineCollectionAPI ) { viewModel = OfflineCollectionRegistrationViewModel(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) configureScrollContent() configureAmountCard() configureMethodCard() configureExplanationCard() configureContextLabel() configureBottomBar() 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().offset(16) make.leading.trailing.equalToSuperview().inset(18) make.height.equalTo(56) make.bottom.equalToSuperview().inset(16) } 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.equalTo(scrollView.contentLayoutGuide).inset(UIEdgeInsets(top: 16, left: 16, bottom: 24, right: 16)) make.width.equalTo(scrollView.frameLayoutGuide).offset(-32) } } 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?.applyState() } } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } viewModel.onRegistrationSuccess = { [weak self] receipt in Task { @MainActor in self?.showSuccess(receipt) } } } override func viewDidLoad() { super.viewDidLoad() applyState() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) guard !hasFocusedAmountField else { return } hasFocusedAmountField = true amountField.becomeFirstResponder() moveAmountCursorToEnd() } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) setGlobalLoadingVisible(false) } private func configureScrollContent() { scrollView.keyboardDismissMode = .interactive scrollView.alwaysBounceVertical = true scrollView.showsVerticalScrollIndicator = false contentStack.axis = .vertical contentStack.spacing = 16 } private func configureAmountCard() { configureCard(amountCard) let titleLabel = makeTitleLabel("收款金额") let helperLabel = UILabel() helperLabel.text = "单笔金额 0.01~99,999.99 元" helperLabel.font = .systemFont(ofSize: 13) helperLabel.textColor = UIColor(hex: 0x7B8494) helperLabel.adjustsFontSizeToFitWidth = true helperLabel.minimumScaleFactor = 0.82 let currencyLabel = UILabel() currencyLabel.text = "¥" currencyLabel.font = .systemFont(ofSize: 34, weight: .semibold) currencyLabel.textColor = UIColor(hex: 0x081739) amountField.placeholder = "0.00" amountField.font = .systemFont(ofSize: 42, weight: .bold) amountField.textColor = UIColor(hex: 0x081739) amountField.tintColor = AppColor.primary amountField.textAlignment = .right amountField.keyboardType = .decimalPad amountField.adjustsFontSizeToFitWidth = true amountField.minimumFontSize = 30 amountField.delegate = self amountField.accessibilityLabel = "收款金额" amountField.accessibilityIdentifier = "offlineCollection.amount" let amountInputStack = UIStackView(arrangedSubviews: [currencyLabel, amountField]) amountInputStack.axis = .horizontal amountInputStack.alignment = .center amountInputStack.spacing = 6 let amountRow = UIView() amountRow.addSubview(helperLabel) amountRow.addSubview(amountInputStack) helperLabel.snp.makeConstraints { make in make.leading.centerY.equalToSuperview() make.trailing.lessThanOrEqualTo(amountInputStack.snp.leading).offset(-8) } amountInputStack.snp.makeConstraints { make in make.trailing.centerY.equalToSuperview() make.width.lessThanOrEqualTo(205) } amountField.snp.makeConstraints { make in make.height.equalTo(56) make.width.greaterThanOrEqualTo(98) } let underline = UIView() underline.backgroundColor = UIColor(hex: 0x1684FC) underline.snp.makeConstraints { make in make.height.equalTo(1) } let quickAmounts: [(String, Int)] = [("¥50", 5_000), ("¥100", 10_000), ("¥200", 20_000), ("¥500", 50_000)] let quickStack = UIStackView() quickStack.axis = .horizontal quickStack.distribution = .fillEqually quickStack.spacing = 12 quickAmounts.forEach { title, fen in let button = UIButton(type: .system) button.tag = fen button.setTitle(title, for: .normal) button.setTitleColor(UIColor(hex: 0x1677FF), for: .normal) button.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium) button.backgroundColor = UIColor(hex: 0xF5F8FD) button.layer.cornerRadius = 8 button.layer.borderWidth = 1 button.layer.borderColor = UIColor(hex: 0xE3E8F0).cgColor button.addTarget(self, action: #selector(quickAmountTapped(_:)), for: .touchUpInside) button.snp.makeConstraints { make in make.height.equalTo(36) } quickStack.addArrangedSubview(button) } let stack = UIStackView(arrangedSubviews: [titleLabel, amountRow, underline, quickStack]) stack.axis = .vertical stack.setCustomSpacing(8, after: titleLabel) stack.setCustomSpacing(2, after: amountRow) stack.setCustomSpacing(22, after: underline) amountCard.addSubview(stack) stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(20) } amountRow.snp.makeConstraints { make in make.height.equalTo(56) } } private func configureMethodCard() { configureCard(methodCard) let titleLabel = makeTitleLabel("收款方式") methodStack.axis = .horizontal methodStack.distribution = .fillEqually methodStack.spacing = 12 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 stack = UIStackView(arrangedSubviews: [titleLabel, methodStack]) stack.axis = .vertical stack.spacing = 20 methodCard.addSubview(stack) stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(20) } methodStack.snp.makeConstraints { make in make.height.equalTo(120) } } private func configureExplanationCard() { configureCard(explanationCard) let iconView = UIImageView(image: UIImage(named: "offline_security_shield")) iconView.contentMode = .scaleAspectFit iconView.accessibilityLabel = "安全说明" let label = UILabel() label.text = "登记后计入今日待补缴,不生成订单" label.font = .systemFont(ofSize: 15) label.textColor = UIColor(hex: 0x22304D) label.numberOfLines = 0 explanationCard.addSubview(iconView) explanationCard.addSubview(label) iconView.snp.makeConstraints { make in make.leading.equalToSuperview().offset(20) make.centerY.equalToSuperview() make.width.height.equalTo(44) } label.snp.makeConstraints { make in make.leading.equalTo(iconView.snp.trailing).offset(14) make.trailing.equalToSuperview().inset(18) make.top.bottom.equalToSuperview().inset(20) } explanationCard.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(72) } } private func configureContextLabel() { contextLabel.text = "当前:\(viewModel.context.collectorName) · \(viewModel.context.storeName) · \(viewModel.context.scenicName)" contextLabel.font = .systemFont(ofSize: 13) contextLabel.textColor = UIColor(hex: 0x7B8494) contextLabel.numberOfLines = 0 contextLabel.textAlignment = .center contextLabel.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(44) } } 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) submitButton.setTitle("确认登记", for: .normal) submitButton.setTitleColor(.white, for: .normal) submitButton.titleLabel?.font = .systemFont(ofSize: 18, weight: .semibold) submitButton.layer.cornerRadius = 10 submitButton.clipsToBounds = true submitButton.accessibilityIdentifier = "offlineCollection.submit" } @MainActor private func applyState() { setGlobalLoadingVisible(viewModel.isSubmitting) if amountField.text != viewModel.amountText { amountField.text = viewModel.amountText moveAmountCursorToEnd() } methodButtons.forEach { method, button in button.setSelected(method == viewModel.paymentMethod) } submitButton.isEnabled = viewModel.canSubmit && !viewModel.isSubmitting submitButton.alpha = viewModel.canSubmit || viewModel.isSubmitting ? 1 : 0.45 } @MainActor private func showSuccess(_ receipt: OfflineCollectionRegistrationReceipt) { setGlobalLoadingVisible(false) amountField.resignFirstResponder() let totalText = receipt.summary.map { OfflineCollectionMoney.display($0.totalAmountFen) } ?? "—" let pendingText = receipt.summary.map { OfflineCollectionMoney.display($0.pendingAmountFen) } ?? "—" let message = """ 本次登记 \(OfflineCollectionMoney.display(receipt.record.amountFen)) 今日累计线下收款 \(totalText) 今日待补缴 \(pendingText) """ let alert = UIAlertController(title: "登记成功", message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "继续登记", style: .default) { [weak self] _ in self?.viewModel.startAnotherRegistration() self?.amountField.becomeFirstResponder() self?.moveAmountCursorToEnd() }) alert.addAction(UIAlertAction(title: "查看今日明细", style: .default) { [weak self] _ in guard let self else { return } self.navigationController?.pushViewController( OfflineCollectionDailyViewController( businessDate: receipt.businessDate, context: self.viewModel.context, api: self.viewModel.api ), animated: true ) }) present(alert, animated: true) } private func setGlobalLoadingVisible(_ visible: Bool) { guard visible != isShowingGlobalLoading else { return } isShowingGlobalLoading = visible visible ? showLoading() : hideLoading() } private func makeTitleLabel(_ text: String) -> UILabel { let label = UILabel() label.text = text label.font = .systemFont(ofSize: 17, weight: .semibold) label.textColor = UIColor(hex: 0x081739) return label } private func configureCard(_ card: UIView) { card.backgroundColor = .white card.layer.cornerRadius = 12 card.layer.shadowColor = UIColor(hex: 0x8FA0B8).cgColor card.layer.shadowOpacity = 0.12 card.layer.shadowRadius = 10 card.layer.shadowOffset = CGSize(width: 0, height: 4) } private func moveAmountCursorToEnd() { guard amountField.isFirstResponder else { return } let end = amountField.endOfDocument amountField.selectedTextRange = amountField.textRange(from: end, to: end) } @objc private func amountChanged() { viewModel.updateAmount(amountField.text ?? "") moveAmountCursorToEnd() } @objc private func quickAmountTapped(_ sender: UIButton) { viewModel.updateAmount(OfflineCollectionMoney.apiAmount(sender.tag)) amountField.becomeFirstResponder() moveAmountCursorToEnd() } @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 swiftRange = Range(range, in: current) else { return false } return OfflineCollectionMoney.acceptsEditingText(current.replacingCharacters(in: swiftRange, with: string)) } } /// 登记页的收款方式单选卡,展示生成的品牌图标和右上角选中标记。 @MainActor final class OfflinePaymentMethodButton: UIControl { let method: OfflineCollectionPaymentMethod private let iconView = UIImageView() private let titleLabel = UILabel() private let checkmarkView = UIImageView(image: UIImage(systemName: "checkmark")) 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: 0xF1F7FF) : .white layer.borderColor = (selected ? UIColor(hex: 0x1684FC) : UIColor(hex: 0xE2E7EF)).cgColor layer.borderWidth = selected ? 1.5 : 1 checkmarkView.isHidden = !selected titleLabel.textColor = UIColor(hex: 0x081739) accessibilityTraits = selected ? [.button, .selected] : .button } private func setupUI() { layer.cornerRadius = 10 clipsToBounds = false accessibilityLabel = method.displayName iconView.image = UIImage(named: method.assetName)?.withRenderingMode(.alwaysOriginal) iconView.contentMode = .scaleAspectFit titleLabel.text = method.displayName titleLabel.font = .systemFont(ofSize: 16, weight: .semibold) titleLabel.textAlignment = .center checkmarkView.tintColor = .white checkmarkView.backgroundColor = UIColor(hex: 0x1684FC) checkmarkView.layer.cornerRadius = 11 checkmarkView.contentMode = .center checkmarkView.isHidden = true let stack = UIStackView(arrangedSubviews: [iconView, titleLabel]) stack.axis = .vertical stack.alignment = .center stack.spacing = 12 stack.isUserInteractionEnabled = false addSubview(stack) addSubview(checkmarkView) stack.snp.makeConstraints { make in make.center.equalToSuperview() } iconView.snp.makeConstraints { make in make.width.height.equalTo(46) } checkmarkView.snp.makeConstraints { make in make.width.height.equalTo(22) make.top.trailing.equalToSuperview().inset(-2) } setSelected(false) } } /// 线下收款页面共用的双端色渐变按钮。 @MainActor final class OfflineCollectionGradientButton: UIButton { private let gradientLayer = CAGradientLayer() init(startColor: UIColor, endColor: UIColor) { super.init(frame: .zero) gradientLayer.colors = [startColor.cgColor, endColor.cgColor] gradientLayer.startPoint = CGPoint(x: 0, y: 0.5) gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) layer.insertSublayer(gradientLayer, at: 0) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() gradientLayer.frame = bounds gradientLayer.cornerRadius = layer.cornerRadius } override var isEnabled: Bool { didSet { gradientLayer.opacity = isEnabled ? 1 : 0.45 } } }