// // OrderDialogControllers.swift // suixinkan // import SnapKit import UIKit /// 普通订单退款弹窗入口,对齐 Android `OrderRefundDialog`。 enum OrderRefundAlertController { static func present( from presenter: UIViewController, order: OrderEntity, onConfirm: @escaping (OrderRefundRequest) -> Void ) { let fullAmount = order.orderAmount.isEmpty ? order.actualPayAmount : order.orderAmount let dialog = OrderRefundDialogView( configuration: .photographerOrder( orderNumber: order.orderNumber, fullRefundAmount: fullAmount ) ) dialog.onMessage = { [weak presenter] message in (presenter as? BaseViewController)?.showToast(message) } dialog.onConfirm = { payload in onConfirm( OrderRefundRequest( orderNumber: order.orderNumber, refundType: payload.refundType, refundAmount: payload.refundAmount, refundReason: payload.refundReason ) ) } dialog.show(in: presenter.view) } } /// 店铺/定金订单退款弹窗入口,对齐 Android `DepositOrderRefundDialog`。 enum DepositOrderRefundAlertController { static func present( from presenter: UIViewController, orderNumber: String, amount: String, refundType: Int, onConfirm: @escaping (Int, String, String) -> Void ) { let dialog = OrderRefundDialogView( configuration: .storeOrder( orderNumber: orderNumber, actualPayAmount: amount, initialMode: OrderRefundMode(rawValue: refundType) ?? .full ) ) dialog.onMessage = { [weak presenter] message in (presenter as? BaseViewController)?.showToast(message) } dialog.onConfirm = { payload in onConfirm(payload.refundType, payload.refundAmount, payload.refundReason) } dialog.show(in: presenter.view) } } /// 退款方式,和 Android 常量 `1=全额退款、3=部分退款` 保持一致。 enum OrderRefundMode: Int { case full = 1 case partial = 3 } /// 退款弹窗校验场景。 enum OrderRefundDialogKind { case photographerOrder case storeOrder } /// 退款弹窗确认后的结构化结果。 struct OrderRefundDialogPayload: Equatable { let refundType: Int let refundAmount: String let refundReason: String } /// 退款弹窗校验失败信息。 struct OrderRefundDialogValidationError: Error, Equatable { let message: String } /// 退款弹窗输入校验与金额格式化逻辑,对齐 Android 两个退款弹窗。 enum OrderRefundDialogValidation { static func filterMoneyInput(_ text: String, maxLength: Int) -> String { var result = "" var hasDot = false var decimalCount = 0 for character in text { guard result.count < maxLength else { break } if character.isNumber { if hasDot { guard decimalCount < 2 else { continue } decimalCount += 1 } result.append(character) } else if character == ".", !hasDot { hasDot = true result.append(character) } } return result } static func validate( kind: OrderRefundDialogKind, mode: OrderRefundMode, amountText: String, reason: String, fullRefundAmount: String, maxRefundAmount: String ) -> Result { let trimmedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines) switch kind { case .photographerOrder: return validatePhotographerOrder( mode: mode, amountText: amountText, reason: trimmedReason, fullRefundAmount: fullRefundAmount ) case .storeOrder: return validateStoreOrder( mode: mode, amountText: amountText, reason: trimmedReason, maxRefundAmount: maxRefundAmount ) } } private static func validatePhotographerOrder( mode: OrderRefundMode, amountText: String, reason: String, fullRefundAmount: String ) -> Result { guard !reason.isEmpty else { return .failure(OrderRefundDialogValidationError(message: "请输入退款原因")) } guard reason.count <= 255 else { return .failure(OrderRefundDialogValidationError(message: "退款原因最多255个字符")) } if mode == .partial, amountText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return .failure(OrderRefundDialogValidationError(message: "请输入退款金额")) } let refundAmount = mode == .full ? fullRefundAmount : amountText return .success( OrderRefundDialogPayload( refundType: mode.rawValue, refundAmount: refundAmount, refundReason: reason ) ) } private static func validateStoreOrder( mode: OrderRefundMode, amountText: String, reason: String, maxRefundAmount: String ) -> Result { guard reason.count >= 5 else { return .failure(OrderRefundDialogValidationError(message: "退款原因至少5个字符")) } guard reason.count <= 255 else { return .failure(OrderRefundDialogValidationError(message: "退款原因最多255个字符")) } guard mode == .partial else { return .success( OrderRefundDialogPayload( refundType: mode.rawValue, refundAmount: "0", refundReason: reason ) ) } let trimmedAmount = amountText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedAmount.isEmpty else { return .failure(OrderRefundDialogValidationError(message: "请输入退款金额")) } guard let amount = Double(trimmedAmount), amount > 0 else { return .failure(OrderRefundDialogValidationError(message: "请输入有效的退款金额")) } let maxAmount = parseAmount(maxRefundAmount) guard maxAmount <= 0 || amount <= maxAmount else { return .failure(OrderRefundDialogValidationError(message: "退款金额不能大于实际支付金额")) } return .success( OrderRefundDialogPayload( refundType: mode.rawValue, refundAmount: String(format: "%.2f", amount), refundReason: reason ) ) } private static func parseAmount(_ text: String) -> Double { text .replacingOccurrences(of: ",", with: "") .replacingOccurrences(of: "¥", with: "") .trimmingCharacters(in: .whitespacesAndNewlines) .toDoubleOrZero } } private extension String { var toDoubleOrZero: Double { Double(self) ?? 0 } } /// 自定义订单退款弹窗视图,对齐 Android `OrderRefundDialog` / `DepositOrderRefundDialog`。 final class OrderRefundDialogView: UIView, UITextViewDelegate { /// 退款弹窗展示与校验配置,用于区分普通订单和店铺/定金订单。 struct Configuration { let kind: OrderRefundDialogKind let orderNumber: String let fullRefundAmount: String let maxRefundAmount: String let initialMode: OrderRefundMode let remarkPlaceholder: String let moneyMaxLength: Int static func photographerOrder(orderNumber: String, fullRefundAmount: String) -> Configuration { Configuration( kind: .photographerOrder, orderNumber: orderNumber, fullRefundAmount: fullRefundAmount, maxRefundAmount: fullRefundAmount, initialMode: .full, remarkPlaceholder: "请输入退款原因(必填)", moneyMaxLength: 10 ) } static func storeOrder( orderNumber: String, actualPayAmount: String, initialMode: OrderRefundMode ) -> Configuration { Configuration( kind: .storeOrder, orderNumber: orderNumber, fullRefundAmount: "0", maxRefundAmount: actualPayAmount, initialMode: initialMode, remarkPlaceholder: "请输入退款原因(必填,5-255字)", moneyMaxLength: 15 ) } } var onConfirm: ((OrderRefundDialogPayload) -> Void)? var onMessage: ((String) -> Void)? private let configuration: Configuration private var selectedMode: OrderRefundMode private var keyboardObservers: [NSObjectProtocol] = [] private var cardCenterYConstraint: Constraint? private let dimView = UIView() private let cardView = UIView() private let titleLabel = UILabel() private let separatorView = UIView() private let orderTitleLabel = UILabel() private let orderNumberLabel = UILabel() private let refundModeTitleLabel = UILabel() private let modeButtonStack = UIStackView() private let fullRefundButton = UIButton(type: .system) private let partialRefundButton = UIButton(type: .system) private let amountGroupView = UIView() private let amountTitleLabel = UILabel() private let amountContainerView = UIView() private let amountField = UITextField() private let remarkTitleLabel = UILabel() private let remarkContainerView = UIView() private let remarkTextView = UITextView() private let remarkPlaceholderLabel = UILabel() private let actionRow = UIStackView() private let cancelButton = UIButton(type: .system) private let confirmButton = UIButton(type: .system) init(configuration: Configuration) { self.configuration = configuration self.selectedMode = configuration.initialMode super.init(frame: .zero) setupUI() applyMode(animated: false) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { unregisterKeyboardObservers() } func show(in parent: UIView) { frame = parent.bounds autoresizingMask = [.flexibleWidth, .flexibleHeight] alpha = 0 parent.addSubview(self) registerKeyboardObservers() UIView.animate(withDuration: 0.2) { self.alpha = 1 } } func dismiss() { endEditing(true) unregisterKeyboardObservers() UIView.animate(withDuration: 0.2, animations: { self.alpha = 0 }, completion: { _ in self.removeFromSuperview() }) } private func setupUI() { dimView.backgroundColor = UIColor.black.withAlphaComponent(0.2) dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped))) cardView.backgroundColor = .white cardView.layer.cornerRadius = AppRadius.md cardView.clipsToBounds = true titleLabel.text = "订单退款" titleLabel.font = .systemFont(ofSize: 18, weight: .bold) titleLabel.textColor = .black separatorView.backgroundColor = UIColor(hex: 0xF4F4F4) configureSectionLabel(orderTitleLabel, text: "订单号") orderNumberLabel.text = configuration.orderNumber orderNumberLabel.font = .systemFont(ofSize: 16) orderNumberLabel.textColor = .black orderNumberLabel.numberOfLines = 0 configureSectionLabel(refundModeTitleLabel, text: "退款方式") configureModeButton(fullRefundButton, title: "全额退款") configureModeButton(partialRefundButton, title: "部分退款") fullRefundButton.addTarget(self, action: #selector(fullRefundTapped), for: .touchUpInside) partialRefundButton.addTarget(self, action: #selector(partialRefundTapped), for: .touchUpInside) modeButtonStack.axis = .horizontal modeButtonStack.spacing = AppSpacing.md modeButtonStack.distribution = .fillEqually configureSectionLabel(amountTitleLabel, text: "退款金额") amountContainerView.layer.borderWidth = 1 amountContainerView.layer.borderColor = UIColor(hex: 0xB6BECA).cgColor amountContainerView.layer.cornerRadius = AppRadius.sm amountField.placeholder = "请输入退款金额" amountField.font = .systemFont(ofSize: 14) amountField.textColor = AppColor.textPrimary amountField.keyboardType = .decimalPad amountField.borderStyle = .none amountField.addTarget(self, action: #selector(amountChanged), for: .editingChanged) configureSectionLabel(remarkTitleLabel, text: "退款备注") remarkContainerView.layer.borderWidth = 1 remarkContainerView.layer.borderColor = UIColor(hex: 0xB6BECA).cgColor remarkContainerView.layer.cornerRadius = AppRadius.sm remarkTextView.font = .systemFont(ofSize: 14) remarkTextView.textColor = AppColor.textPrimary remarkTextView.backgroundColor = .clear remarkTextView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 10, right: 8) remarkTextView.delegate = self remarkPlaceholderLabel.text = configuration.remarkPlaceholder remarkPlaceholderLabel.font = .systemFont(ofSize: 14) remarkPlaceholderLabel.textColor = AppColor.textTertiary actionRow.axis = .horizontal actionRow.alignment = .center actionRow.spacing = AppSpacing.xs configureActionButton(cancelButton, title: "取消", foreground: AppColor.primary, background: AppColor.primaryLight) configureActionButton(confirmButton, title: "确认", foreground: .white, background: AppColor.primary) cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) addSubview(dimView) addSubview(cardView) cardView.addSubview(titleLabel) cardView.addSubview(separatorView) cardView.addSubview(orderTitleLabel) cardView.addSubview(orderNumberLabel) cardView.addSubview(refundModeTitleLabel) cardView.addSubview(modeButtonStack) modeButtonStack.addArrangedSubview(fullRefundButton) modeButtonStack.addArrangedSubview(partialRefundButton) cardView.addSubview(amountGroupView) amountGroupView.addSubview(amountTitleLabel) amountGroupView.addSubview(amountContainerView) amountContainerView.addSubview(amountField) cardView.addSubview(remarkTitleLabel) cardView.addSubview(remarkContainerView) remarkContainerView.addSubview(remarkTextView) remarkContainerView.addSubview(remarkPlaceholderLabel) cardView.addSubview(actionRow) actionRow.addArrangedSubview(cancelButton) actionRow.addArrangedSubview(confirmButton) dimView.snp.makeConstraints { make in make.edges.equalToSuperview() } cardView.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(AppSpacing.md) cardCenterYConstraint = make.centerY.equalToSuperview().constraint } titleLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(AppSpacing.sm) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) } separatorView.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.height.equalTo(1) } orderTitleLabel.snp.makeConstraints { make in make.top.equalTo(separatorView.snp.bottom).offset(AppSpacing.md) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) } orderNumberLabel.snp.makeConstraints { make in make.top.equalTo(orderTitleLabel.snp.bottom).offset(AppSpacing.xs) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) } refundModeTitleLabel.snp.makeConstraints { make in make.top.equalTo(orderNumberLabel.snp.bottom).offset(AppSpacing.xl) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) } modeButtonStack.snp.makeConstraints { make in make.top.equalTo(refundModeTitleLabel.snp.bottom).offset(AppSpacing.xs) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.height.equalTo(46) } amountGroupView.snp.makeConstraints { make in make.top.equalTo(modeButtonStack.snp.bottom).offset(AppSpacing.xl) make.leading.trailing.equalToSuperview() make.height.equalTo(73) } amountTitleLabel.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview().inset(UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: 0, right: AppSpacing.md)) } amountContainerView.snp.makeConstraints { make in make.top.equalTo(amountTitleLabel.snp.bottom).offset(AppSpacing.xs) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.height.equalTo(46) } amountField.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(AppSpacing.sm) make.centerY.equalToSuperview() } remarkTitleLabel.snp.makeConstraints { make in make.top.equalTo(amountGroupView.snp.bottom).offset(AppSpacing.xl) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) } remarkContainerView.snp.makeConstraints { make in make.top.equalTo(remarkTitleLabel.snp.bottom).offset(AppSpacing.xs) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.height.equalTo(104) } remarkTextView.snp.makeConstraints { make in make.edges.equalToSuperview() } remarkPlaceholderLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(16) make.leading.trailing.equalToSuperview().inset(AppSpacing.sm) } actionRow.snp.makeConstraints { make in make.top.equalTo(remarkContainerView.snp.bottom).offset(20) make.trailing.equalToSuperview().inset(AppSpacing.md) make.bottom.equalToSuperview().inset(AppSpacing.lg) } [cancelButton, confirmButton].forEach { button in button.snp.makeConstraints { make in make.width.equalTo(84) make.height.equalTo(38) } } } private func configureSectionLabel(_ label: UILabel, text: String) { label.text = text label.font = .systemFont(ofSize: 16) label.textColor = UIColor(hex: 0x4B5563) } private func configureModeButton(_ button: UIButton, title: String) { button.setTitle(title, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 14) button.layer.cornerRadius = AppRadius.sm button.layer.borderWidth = 1 } private func configureActionButton(_ button: UIButton, title: String, foreground: UIColor, background: UIColor) { button.setTitle(title, for: .normal) button.setTitleColor(foreground, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) button.backgroundColor = background button.layer.cornerRadius = AppRadius.sm } private func applyMode(animated: Bool) { let amountHidden = selectedMode == .full amountGroupView.isHidden = amountHidden amountGroupView.snp.updateConstraints { make in make.height.equalTo(amountHidden ? 0 : 73) } remarkTitleLabel.snp.remakeConstraints { make in make.top.equalTo((amountHidden ? modeButtonStack : amountGroupView).snp.bottom).offset(AppSpacing.xl) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) } updateModeButton(fullRefundButton, selected: selectedMode == .full) updateModeButton(partialRefundButton, selected: selectedMode == .partial) let changes = { self.layoutIfNeeded() } if animated { UIView.animate(withDuration: 0.2, animations: changes) } else { changes() } } private func updateModeButton(_ button: UIButton, selected: Bool) { button.backgroundColor = selected ? AppColor.primary : .white button.layer.borderColor = selected ? UIColor.clear.cgColor : UIColor(hex: 0xB6BECA).cgColor button.setTitleColor(selected ? .white : UIColor(hex: 0x4B5563), for: .normal) } private func registerKeyboardObservers() { unregisterKeyboardObservers() let center = NotificationCenter.default keyboardObservers = [ center.addObserver( forName: UIResponder.keyboardWillChangeFrameNotification, object: nil, queue: .main ) { [weak self] notification in self?.handleKeyboardNotification(notification) }, center.addObserver( forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main ) { [weak self] notification in self?.handleKeyboardNotification(notification) } ] } private func unregisterKeyboardObservers() { keyboardObservers.forEach(NotificationCenter.default.removeObserver) keyboardObservers.removeAll() } private func handleKeyboardNotification(_ notification: Notification) { layoutIfNeeded() guard let userInfo = notification.userInfo else { return } let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25 let curveRaw = (userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue ?? UInt(UIView.AnimationCurve.easeInOut.rawValue) let options = UIView.AnimationOptions(rawValue: curveRaw << 16) let offset = keyboardAvoidanceOffset(from: userInfo) cardCenterYConstraint?.update(offset: offset) UIView.animate(withDuration: duration, delay: 0, options: options) { self.layoutIfNeeded() } } private func keyboardAvoidanceOffset(from userInfo: [AnyHashable: Any]) -> CGFloat { guard let keyboardFrameValue = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { return 0 } let keyboardFrame = convert(keyboardFrameValue.cgRectValue, from: nil) guard keyboardFrame.minY < bounds.height else { return 0 } let desiredBottom = keyboardFrame.minY - AppSpacing.sm let overlap = cardView.frame.maxY - desiredBottom return overlap > 0 ? -overlap : 0 } func textViewDidChange(_ textView: UITextView) { if textView.text.count > 255 { textView.text = String(textView.text.prefix(255)) } remarkPlaceholderLabel.isHidden = !textView.text.isEmpty } @objc private func amountChanged() { let filtered = OrderRefundDialogValidation.filterMoneyInput( amountField.text ?? "", maxLength: configuration.moneyMaxLength ) if amountField.text != filtered { amountField.text = filtered } } @objc private func fullRefundTapped() { selectedMode = .full amountField.text = configuration.kind == .storeOrder ? configuration.maxRefundAmount : configuration.fullRefundAmount applyMode(animated: true) } @objc private func partialRefundTapped() { selectedMode = .partial amountField.text = "" applyMode(animated: true) amountField.becomeFirstResponder() } @objc private func cancelTapped() { dismiss() } @objc private func confirmTapped() { let result = OrderRefundDialogValidation.validate( kind: configuration.kind, mode: selectedMode, amountText: amountField.text ?? "", reason: remarkTextView.text ?? "", fullRefundAmount: configuration.fullRefundAmount, maxRefundAmount: configuration.maxRefundAmount ) switch result { case .success(let payload): onConfirm?(payload) dismiss() case .failure(let error): onMessage?(error.message) } } } enum OrderSignInAlertController { static func present(from presenter: UIViewController, response: OrderSignInResponse) { let message = """ 项目:\(response.projectName) 景区:\(response.scenicName) 金额:¥\(response.orderAmount) 手机号:\(response.userPhone) """ let alert = UIAlertController(title: response.checkIn ? "签到成功" : "核销结果", message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "确定", style: .default)) presenter.present(alert, animated: true) } } enum WriteOffResultAlertController { static func present(from presenter: UIViewController, response: WriteOffOrderResponse) { let message = """ 项目:\(response.projectName) 景区:\(response.scenicName) 金额:¥\(response.orderAmount) 手机号:\(response.userPhone) """ let alert = UIAlertController(title: response.orderVerify ? "核销成功" : "核销结果", message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "确定", style: .default)) presenter.present(alert, animated: true) } } enum SendGiftAlertController { static func present( from presenter: UIViewController, photoNum: Int, videoNum: Int, onConfirm: @escaping (Int, Int) -> Void ) { let alert = UIAlertController(title: "赠送修图", message: nil, preferredStyle: .alert) alert.addTextField { $0.placeholder = "赠送照片数"; $0.keyboardType = .numberPad; $0.text = "\(photoNum)" } alert.addTextField { $0.placeholder = "赠送视频数"; $0.keyboardType = .numberPad; $0.text = "\(videoNum)" } alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "确认", style: .default) { _ in let photo = Int(alert.textFields?.first?.text ?? "0") ?? 0 let video = Int(alert.textFields?.last?.text ?? "0") ?? 0 onConfirm(photo, video) }) presenter.present(alert, animated: true) } }