Files
suixinkan_uikit/suixinkan/UI/Orders/OrderDialogControllers.swift

653 lines
25 KiB
Swift

//
// 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<OrderRefundDialogPayload, OrderRefundDialogValidationError> {
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<OrderRefundDialogPayload, OrderRefundDialogValidationError> {
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<OrderRefundDialogPayload, OrderRefundDialogValidationError> {
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: KeyboardAvoidingDialogView, UITextViewDelegate {
/// 退款弹窗局部布局尺寸,保持内容紧凑但不牺牲基础点击区域。
private enum Layout {
static let sectionGap: CGFloat = AppSpacing.lg
static let formGap: CGFloat = AppSpacing.md
static let controlHeight: CGFloat = AppSpacing.minTouchTarget
static let amountGroupHeight: CGFloat = 67
static let remarkHeight: CGFloat = 88
static let actionTop: CGFloat = 14
static let bottomInset: CGFloat = AppSpacing.md
}
/// 退款弹窗展示与校验配置,用于区分普通订单和店铺/定金订单。
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 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")
}
func show(in parent: UIView) {
frame = parent.bounds
autoresizingMask = [.flexibleWidth, .flexibleHeight]
alpha = 0
parent.addSubview(self)
UIView.animate(withDuration: 0.2) {
self.alpha = 1
}
}
func dismiss() {
endEditing(true)
UIView.animate(withDuration: 0.2, animations: {
self.alpha = 0
}, completion: { _ in
self.removeFromSuperview()
})
}
private func setupUI() {
keyboardAvoidingContentView = cardView
keyboardAvoidingMinimumSpacing = AppSpacing.sm
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)
keyboardAvoidingCenterYConstraint = 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(Layout.sectionGap)
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(Layout.controlHeight)
}
amountGroupView.snp.makeConstraints { make in
make.top.equalTo(modeButtonStack.snp.bottom).offset(Layout.formGap)
make.leading.trailing.equalToSuperview()
make.height.equalTo(Layout.amountGroupHeight)
}
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(Layout.controlHeight)
}
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(Layout.formGap)
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(Layout.remarkHeight)
}
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(Layout.actionTop)
make.trailing.equalToSuperview().inset(AppSpacing.md)
make.bottom.equalToSuperview().inset(Layout.bottomInset)
}
[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 : Layout.amountGroupHeight)
}
remarkTitleLabel.snp.remakeConstraints { make in
make.top.equalTo((amountHidden ? modeButtonStack : amountGroupView).snp.bottom).offset(Layout.formGap)
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)
}
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)
}
}