// // PaymentCollectionDetailsViewController.swift // suixinkan // import Kingfisher import SnapKit import UIKit /// 收款详情页,展示收款二维码、设金额、保存二维码与语音开关。 final class PaymentCollectionDetailsViewController: BaseViewController { private let viewModel = PaymentCollectionDetailsViewModel() private let paymentAPI = NetworkServices.shared.paymentAPI private let scrollView = UIScrollView() private let contentContainerView = UIView() private let contentStack = UIStackView() private let backgroundImageView = UIImageView() private let brandHeaderStack = UIStackView() private let brandLogoImageView = UIImageView() private let brandTitleLabel = UILabel() private let brandSubtitleLabel = UILabel() private let qrCardView = UIView() private let scenicNameLabel = UILabel() private let qrContainerView = UIView() private let qrImageView = UIImageView() private let qrPlaceholderView = UIView() private let qrErrorLabel = UILabel() private let refreshButton = UIButton(type: .system) private let setAmountButton = UIButton(type: .system) private let saveQRButton = UIButton(type: .system) private let actionRow = UIStackView() private let infoCardView = UIView() private let scenicRow = PaymentInfoRowView(title: "景区名称") private let merchantRow = PaymentInfoRowView(title: "收款方") private let staffRow = PaymentInfoRowView(title: "员工 ID") private let photographerRow = PaymentInfoRowView(title: "摄影师名称") private let storeRow = PaymentInfoRowView(title: "店铺名称") private let recordRow = PaymentNavigationRowView(title: "收款记录") private let settingsCardView = UIView() private let voiceCardView = UIView() private let voiceTitleLabel = UILabel() private let voiceSwitch = UISwitch() private var amountDialog: PaymentSetAmountDialogView? private var appliedBrandConfig: PayPageConfig? private var appliedBrandingRefreshVersion = -1 private var previousStandardAppearance: UINavigationBarAppearance? private var previousScrollEdgeAppearance: UINavigationBarAppearance? private var previousCompactAppearance: UINavigationBarAppearance? private var previousNavigationTintColor: UIColor? private var previousNavigationTranslucency = false private var brandScreenHeight: CGFloat { UIScreen.main.bounds.height } private var brandLogoHeight: CGFloat { if brandScreenHeight <= 700 { return 82 } if brandScreenHeight <= 820 { return 96 } return 112 } private var brandQRContainerSize: CGFloat { if brandScreenHeight <= 700 { return 170 } if brandScreenHeight <= 820 { return 190 } return 210 } private var brandQRImageSize: CGFloat { if brandScreenHeight <= 700 { return 146 } if brandScreenHeight <= 820 { return 166 } return 182 } private var brandSectionSpacing: CGFloat { if brandScreenHeight <= 700 { return 8 } if brandScreenHeight <= 820 { return 10 } return 12 } private var brandHeaderToQRSpacing: CGFloat { if brandScreenHeight <= 700 { return 16 } if brandScreenHeight <= 820 { return 18 } return 20 } override func setupNavigationBar() { title = viewModel.usesNalatiBranding ? nil : "收款详情" } override func setupUI() { let usesBranding = viewModel.usesNalatiBranding view.backgroundColor = usesBranding ? .clear : AppColor.pageBackground scrollView.showsVerticalScrollIndicator = !usesBranding scrollView.isScrollEnabled = !usesBranding scrollView.alwaysBounceVertical = false contentStack.axis = .vertical contentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.md qrCardView.backgroundColor = usesBranding ? .clear : .white qrCardView.layer.cornerRadius = AppRadius.lg scenicNameLabel.font = .systemFont(ofSize: 16, weight: .medium) scenicNameLabel.textColor = AppColor.textPrimary scenicNameLabel.textAlignment = .center qrImageView.contentMode = .scaleAspectFit qrImageView.isHidden = true if usesBranding { qrContainerView.backgroundColor = .white qrContainerView.layer.cornerRadius = 10 qrContainerView.clipsToBounds = true qrContainerView.isHidden = true qrContainerView.addSubview(qrImageView) } qrPlaceholderView.backgroundColor = AppColor.inputBackground qrPlaceholderView.layer.cornerRadius = usesBranding ? 10 : 24 qrPlaceholderView.isHidden = true qrErrorLabel.text = "未选景区或网络问题" qrErrorLabel.font = .systemFont(ofSize: 14) qrErrorLabel.textColor = AppColor.danger qrErrorLabel.textAlignment = .center refreshButton.setTitle("点击刷新", for: .normal) refreshButton.setTitleColor(AppColor.primary, for: .normal) refreshButton.titleLabel?.font = .systemFont(ofSize: 14) actionRow.axis = .horizontal actionRow.alignment = .center actionRow.spacing = usesBranding ? AppSpacing.lg : AppSpacing.xl if usesBranding { actionRow.backgroundColor = UIColor.white.withAlphaComponent(0.88) actionRow.layer.cornerRadius = 24 actionRow.isLayoutMarginsRelativeArrangement = true actionRow.directionalLayoutMargins = NSDirectionalEdgeInsets( top: 10, leading: 24, bottom: 10, trailing: 24 ) } configureLinkButton(setAmountButton, title: "设置金额") configureLinkButton(saveQRButton, title: "保存二维码") configureCard(infoCardView, branded: usesBranding) recordRow.backgroundColor = usesBranding ? .clear : .white recordRow.layer.cornerRadius = usesBranding ? 0 : AppRadius.lg voiceCardView.backgroundColor = usesBranding ? .clear : .white voiceCardView.layer.cornerRadius = usesBranding ? 0 : AppRadius.lg voiceTitleLabel.text = "收款到账语音提醒" voiceTitleLabel.font = .systemFont(ofSize: 14) voiceTitleLabel.textColor = UIColor(hex: 0x4B5563) voiceSwitch.onTintColor = AppColor.primary if usesBranding { configureBrandHeader() backgroundImageView.contentMode = .scaleAspectFill backgroundImageView.clipsToBounds = true backgroundImageView.image = UIImage(named: "payment_nalati_background") view.addSubview(backgroundImageView) } view.addSubview(scrollView) scrollView.addSubview(contentContainerView) contentContainerView.addSubview(contentStack) if usesBranding { contentStack.addArrangedSubview(brandHeaderStack) contentStack.setCustomSpacing(brandHeaderToQRSpacing, after: brandHeaderStack) } contentStack.addArrangedSubview(qrCardView) contentStack.addArrangedSubview(infoCardView) if usesBranding { configureCard(settingsCardView, branded: true) contentStack.addArrangedSubview(settingsCardView) } else { contentStack.addArrangedSubview(recordRow) contentStack.addArrangedSubview(voiceCardView) } let qrDisplayView = usesBranding ? qrContainerView : qrImageView let qrContentStack = UIStackView(arrangedSubviews: [ scenicNameLabel, qrDisplayView, qrPlaceholderView, actionRow, ]) qrContentStack.axis = .vertical qrContentStack.alignment = .center qrContentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.lg qrCardView.addSubview(qrContentStack) qrPlaceholderView.addSubview(qrErrorLabel) qrPlaceholderView.addSubview(refreshButton) actionRow.addArrangedSubview(setAmountButton) if usesBranding { let divider = UIView() divider.backgroundColor = UIColor(hex: 0xD6E1EE) actionRow.addArrangedSubview(divider) divider.snp.makeConstraints { make in make.width.equalTo(1) make.height.equalTo(24) } } actionRow.addArrangedSubview(saveQRButton) let infoRows = usesBranding ? [photographerRow, storeRow] : [scenicRow, merchantRow, staffRow] let infoStack = UIStackView(arrangedSubviews: infoRows) infoStack.axis = .vertical infoCardView.addSubview(infoStack) voiceCardView.addSubview(voiceTitleLabel) voiceCardView.addSubview(voiceSwitch) if usesBranding { let settingsStack = UIStackView(arrangedSubviews: [recordRow, voiceCardView]) settingsStack.axis = .vertical settingsCardView.addSubview(settingsStack) settingsStack.snp.makeConstraints { make in make.edges.equalToSuperview() } let divider = UIView() divider.backgroundColor = UIColor(hex: 0xD9E2EA).withAlphaComponent(0.75) settingsCardView.addSubview(divider) divider.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.centerY.equalToSuperview() make.height.equalTo(0.5) } } qrContentStack.snp.makeConstraints { make in let verticalInset: CGFloat = usesBranding ? 0 : 32 make.edges.equalToSuperview().inset( UIEdgeInsets(top: verticalInset, left: 16, bottom: verticalInset, right: 16) ) } scenicNameLabel.isHidden = usesBranding scenicNameLabel.snp.makeConstraints { make in make.leading.trailing.equalToSuperview() } if usesBranding { qrContainerView.snp.makeConstraints { make in make.width.height.equalTo(brandQRContainerSize) } qrImageView.snp.makeConstraints { make in make.center.equalToSuperview() make.width.height.equalTo(brandQRImageSize) } } else { qrImageView.snp.makeConstraints { make in make.width.height.equalTo(182) } } qrPlaceholderView.snp.makeConstraints { make in make.width.height.equalTo(usesBranding ? brandQRContainerSize : 182) } qrErrorLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(56) make.leading.trailing.equalToSuperview().inset(AppSpacing.sm) } refreshButton.snp.makeConstraints { make in make.top.equalTo(qrErrorLabel.snp.bottom).offset(AppSpacing.xs) make.centerX.equalToSuperview() } infoStack.snp.makeConstraints { make in make.edges.equalToSuperview() } voiceTitleLabel.snp.makeConstraints { make in make.leading.equalToSuperview().inset(AppSpacing.md) make.centerY.equalToSuperview() } voiceSwitch.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(AppSpacing.md) make.centerY.equalToSuperview() } voiceCardView.snp.makeConstraints { make in make.height.equalTo(AppSpacing.formRowHeight) } } override func setupConstraints() { if viewModel.usesNalatiBranding { backgroundImageView.snp.makeConstraints { make in make.edges.equalToSuperview() } } scrollView.snp.makeConstraints { make in if viewModel.usesNalatiBranding { make.leading.trailing.equalToSuperview() make.top.equalTo(view.safeAreaLayoutGuide.snp.top).offset(-44) make.bottom.equalTo(view.safeAreaLayoutGuide) } else { make.edges.equalTo(view.safeAreaLayoutGuide) } } contentContainerView.snp.makeConstraints { make in make.edges.equalToSuperview() make.width.equalTo(scrollView.snp.width) if viewModel.usesNalatiBranding { make.height.equalTo(scrollView.snp.height) } } contentStack.snp.makeConstraints { make in make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2) if viewModel.usesNalatiBranding { make.centerX.centerY.equalToSuperview() make.top.greaterThanOrEqualToSuperview() make.bottom.lessThanOrEqualToSuperview() } else { make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset) } } } override func bindActions() { viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyViewModel() } } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } setAmountButton.addTarget(self, action: #selector(setAmountTapped), for: .touchUpInside) saveQRButton.addTarget(self, action: #selector(saveQRTapped), for: .touchUpInside) refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside) recordRow.addTarget(self, action: #selector(recordTapped), for: .touchUpInside) voiceSwitch.addTarget(self, action: #selector(voiceSwitchChanged), for: .valueChanged) } override func viewDidLoad() { super.viewDidLoad() applyViewModel() Task { await loadData() } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) applyBrandNavigationAppearanceIfNeeded() } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) restoreNavigationAppearanceIfNeeded() } override var preferredStatusBarStyle: UIStatusBarStyle { viewModel.usesNalatiBranding ? .darkContent : .default } private func loadData() async { async let pageConfig: Void = viewModel.loadPayPageConfig(api: paymentAPI) showLoading() await viewModel.loadPayCode(api: paymentAPI) hideLoading() await pageConfig } @MainActor private func applyViewModel() { scenicNameLabel.text = viewModel.displayScenicName scenicRow.setValue(viewModel.displayScenicName) merchantRow.setValue(viewModel.merchantInfo.company) staffRow.setValue(viewModel.staffId.isEmpty ? "-" : viewModel.staffId) photographerRow.setValue(viewModel.displayPhotographerName) storeRow.setValue(viewModel.displayStoreNames) voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen applyBrandConfigIfNeeded() if let image = viewModel.qrImage { qrImageView.image = image qrImageView.isHidden = false qrContainerView.isHidden = !viewModel.usesNalatiBranding qrPlaceholderView.isHidden = true actionRow.isHidden = false } else { qrImageView.isHidden = true qrContainerView.isHidden = true qrPlaceholderView.isHidden = false actionRow.isHidden = true } if viewModel.showAmountDialog { presentAmountDialogIfNeeded() } else { amountDialog?.dismiss() amountDialog = nil } } private func presentAmountDialogIfNeeded() { guard amountDialog == nil else { amountDialog?.apply(amount: viewModel.amount, remark: viewModel.remark) return } let dialog = PaymentSetAmountDialogView() dialog.onAmountChange = { [weak self] value in self?.viewModel.updateAmount(value) } dialog.onRemarkChange = { [weak self] value in self?.viewModel.updateRemark(value) } dialog.onConfirm = { [weak self] in self?.viewModel.confirmAmount() } dialog.onCancel = { [weak self] in self?.viewModel.hideAmountDialog() } dialog.apply(amount: viewModel.amount, remark: viewModel.remark) dialog.show(in: view) amountDialog = dialog } private func configureLinkButton(_ button: UIButton, title: String) { button.setTitle(title, for: .normal) button.setTitleColor(AppColor.primary, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 14) } private func configureCard(_ view: UIView, branded: Bool) { view.backgroundColor = branded ? UIColor.white.withAlphaComponent(0.9) : .white view.layer.cornerRadius = branded ? 18 : AppRadius.lg view.clipsToBounds = true } private func configureBrandHeader() { brandHeaderStack.axis = .vertical brandHeaderStack.alignment = .center brandHeaderStack.spacing = brandScreenHeight <= 700 ? 6 : 8 brandLogoImageView.image = UIImage(named: "payment_nalati_logo") brandLogoImageView.contentMode = .scaleAspectFit brandLogoImageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) brandTitleLabel.font = .systemFont( ofSize: brandScreenHeight <= 700 ? 21 : 23, weight: .bold ) brandTitleLabel.textColor = UIColor(hex: 0x102D54) brandTitleLabel.textAlignment = .center brandTitleLabel.numberOfLines = 1 brandTitleLabel.adjustsFontSizeToFitWidth = true brandTitleLabel.minimumScaleFactor = 0.8 brandSubtitleLabel.font = .systemFont( ofSize: brandScreenHeight <= 700 ? 15 : 16, weight: .medium ) brandSubtitleLabel.textColor = UIColor(hex: 0x183A61) brandSubtitleLabel.textAlignment = .center let subtitleRow = UIStackView() subtitleRow.axis = .horizontal subtitleRow.alignment = .center subtitleRow.spacing = 9 let leftLine = makeBrandLine() let leftDiamond = makeBrandDiamond() let rightDiamond = makeBrandDiamond() let rightLine = makeBrandLine() [leftLine, leftDiamond, brandSubtitleLabel, rightDiamond, rightLine].forEach( subtitleRow.addArrangedSubview ) brandHeaderStack.addArrangedSubview(brandLogoImageView) brandHeaderStack.addArrangedSubview(brandTitleLabel) brandHeaderStack.addArrangedSubview(subtitleRow) brandHeaderStack.setCustomSpacing( brandScreenHeight <= 700 ? 8 : 10, after: brandLogoImageView ) brandLogoImageView.snp.makeConstraints { make in make.width.equalToSuperview().inset(8) make.height.equalTo(brandLogoHeight) } brandTitleLabel.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(8) } [leftLine, rightLine].forEach { line in line.snp.makeConstraints { make in make.width.equalTo(42) make.height.equalTo(1) } } [leftDiamond, rightDiamond].forEach { diamond in diamond.snp.makeConstraints { make in make.width.height.equalTo(6) } } } private func makeBrandLine() -> UIView { let view = UIView() view.backgroundColor = UIColor(hex: 0x27496D).withAlphaComponent(0.75) return view } private func makeBrandDiamond() -> UIView { let view = UIView() view.backgroundColor = UIColor(hex: 0x27496D) view.transform = CGAffineTransform(rotationAngle: .pi / 4) return view } @MainActor private func applyBrandConfigIfNeeded() { guard viewModel.usesNalatiBranding else { return } let config = viewModel.payPageConfig brandTitleLabel.text = config.title brandSubtitleLabel.text = config.subtitle let versionChanged = appliedBrandingRefreshVersion != viewModel.brandingRefreshVersion guard appliedBrandConfig != config || versionChanged else { return } let forceRefresh = appliedBrandingRefreshVersion >= 0 && versionChanged setBrandImage( backgroundImageView, urlString: config.background, fallbackName: "payment_nalati_background", forceRefresh: forceRefresh ) setBrandImage( brandLogoImageView, urlString: config.logo, fallbackName: "payment_nalati_logo", forceRefresh: forceRefresh ) appliedBrandConfig = config appliedBrandingRefreshVersion = viewModel.brandingRefreshVersion } private func setBrandImage( _ imageView: UIImageView, urlString: String, fallbackName: String, forceRefresh: Bool ) { let fallback = UIImage(named: fallbackName) let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines) guard let url = URL(string: trimmed), url.scheme?.lowercased() == "https", url.host != nil else { imageView.image = fallback return } var options: KingfisherOptionsInfo = [ .keepCurrentImageWhileLoading, .cacheOriginalImage, .transition(.fade(0.2)), ] if forceRefresh { options.append(.forceRefresh) } imageView.kf.setImage(with: url, placeholder: fallback, options: options) } private func applyBrandNavigationAppearanceIfNeeded() { guard viewModel.usesNalatiBranding, let navigationBar = navigationController?.navigationBar else { return } previousStandardAppearance = navigationBar.standardAppearance previousScrollEdgeAppearance = navigationBar.scrollEdgeAppearance previousCompactAppearance = navigationBar.compactAppearance previousNavigationTintColor = navigationBar.tintColor previousNavigationTranslucency = navigationBar.isTranslucent let appearance = UINavigationBarAppearance() appearance.configureWithTransparentBackground() appearance.shadowColor = .clear appearance.titleTextAttributes = [ .foregroundColor: UIColor(hex: 0x111827), .font: UIFont.app(.title), ] navigationBar.standardAppearance = appearance navigationBar.scrollEdgeAppearance = appearance navigationBar.compactAppearance = appearance navigationBar.tintColor = UIColor(hex: 0x111827) navigationBar.isTranslucent = true } private func restoreNavigationAppearanceIfNeeded() { guard viewModel.usesNalatiBranding, let navigationBar = navigationController?.navigationBar else { return } if let previousStandardAppearance { navigationBar.standardAppearance = previousStandardAppearance } navigationBar.scrollEdgeAppearance = previousScrollEdgeAppearance navigationBar.compactAppearance = previousCompactAppearance navigationBar.tintColor = previousNavigationTintColor navigationBar.isTranslucent = previousNavigationTranslucency previousStandardAppearance = nil previousScrollEdgeAppearance = nil previousCompactAppearance = nil previousNavigationTintColor = nil } @objc private func setAmountTapped() { viewModel.showSetAmountDialog() } @objc private func saveQRTapped() { Task { await viewModel.saveQRCode() } } @objc private func refreshTapped() { Task { showLoading() await viewModel.refresh(api: paymentAPI) hideLoading() } } @objc private func recordTapped() { navigationController?.pushViewController(PaymentCollectionRecordViewController(), animated: true) } @objc private func voiceSwitchChanged() { viewModel.toggleReceiveVoice() } } /// 收款详情信息行。 private final class PaymentInfoRowView: UIView { private let titleLabel = UILabel() private let valueLabel = UILabel() private let divider = UIView() init(title: String) { super.init(frame: .zero) titleLabel.text = title setupUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func setValue(_ value: String) { valueLabel.text = value } private func setupUI() { titleLabel.font = .systemFont(ofSize: 14) titleLabel.textColor = UIColor(hex: 0x4B5563) valueLabel.font = .systemFont(ofSize: 14) valueLabel.textColor = AppColor.textPrimary valueLabel.textAlignment = .right valueLabel.numberOfLines = 0 divider.backgroundColor = UIColor(hex: 0xE5E7EB) addSubview(titleLabel) addSubview(valueLabel) addSubview(divider) snp.makeConstraints { make in make.height.greaterThanOrEqualTo(AppSpacing.formRowHeight) } titleLabel.snp.makeConstraints { make in make.leading.equalToSuperview().inset(AppSpacing.md) make.centerY.equalToSuperview() make.width.equalTo(102) } valueLabel.snp.makeConstraints { make in make.leading.equalTo(titleLabel.snp.trailing).offset(AppSpacing.xs) make.trailing.equalToSuperview().inset(AppSpacing.md) make.top.bottom.equalToSuperview().inset(AppSpacing.sm) } divider.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() make.height.equalTo(0.5) } } } /// 可点击导航行。 private final class PaymentNavigationRowView: UIControl { private let titleLabel = UILabel() private let chevronView = UIImageView() init(title: String) { super.init(frame: .zero) titleLabel.text = title setupUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupUI() { titleLabel.font = .systemFont(ofSize: 14) titleLabel.textColor = UIColor(hex: 0x4B5563) chevronView.image = UIImage(systemName: "chevron.right") chevronView.tintColor = AppColor.textSecondary chevronView.contentMode = .scaleAspectFit addSubview(titleLabel) addSubview(chevronView) snp.makeConstraints { make in make.height.equalTo(AppSpacing.formRowHeight) } titleLabel.snp.makeConstraints { make in make.leading.equalToSuperview().inset(AppSpacing.md) make.centerY.equalToSuperview() } chevronView.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(AppSpacing.md) make.centerY.equalToSuperview() make.width.height.equalTo(16) } } }