feat: update app workflows and permissions

This commit is contained in:
2026-07-09 17:34:00 +08:00
parent 43e6133c21
commit 8e356973bd
44 changed files with 2944 additions and 307 deletions

View File

@ -3,46 +3,651 @@
// suixinkan
//
import SnapKit
import UIKit
/// 退 Android `OrderRefundDialog`
enum OrderRefundAlertController {
static func present(
from presenter: UIViewController,
order: OrderEntity,
onConfirm: @escaping (OrderRefundRequest) -> Void
) {
let alert = UIAlertController(title: "订单退款", message: order.orderNumber, preferredStyle: .alert)
alert.addTextField { $0.placeholder = "退款金额"; $0.text = order.actualPayAmount }
alert.addTextField { $0.placeholder = "退款原因" }
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "确认退款", style: .destructive) { _ in
let amount = alert.textFields?.first?.text ?? order.actualPayAmount
let reason = alert.textFields?.last?.text ?? ""
onConfirm(OrderRefundRequest(orderNumber: order.orderNumber, refundType: 1, refundAmount: amount, refundReason: reason))
})
presenter.present(alert, animated: true)
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 (String, String) -> Void
onConfirm: @escaping (Int, String, String) -> Void
) {
let title = refundType == 3 ? "部分退款" : "全额退款"
let alert = UIAlertController(title: title, message: orderNumber, preferredStyle: .alert)
alert.addTextField { $0.placeholder = "退款金额"; $0.text = amount }
alert.addTextField { $0.placeholder = "退款原因" }
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "确认", style: .destructive) { _ in
let refundAmount = alert.textFields?.first?.text ?? amount
let reason = alert.textFields?.last?.text ?? ""
onConfirm(refundAmount, reason)
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: 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()
})
presenter.present(alert, animated: true)
}
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)
}
}
}