完善景区排队设置页与全局按钮配置迁移。

重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 16:21:53 +08:00
parent efb3925257
commit 6336feff27
49 changed files with 1996 additions and 590 deletions

View File

@ -0,0 +1,396 @@
//
// ScenicQueueOverlayViews.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android
final class ScenicQueueTabStripView: UIView {
private let stackView = UIStackView()
private let indicator = UIView()
private let divider = UIView()
private var buttons: [UIButton] = []
///
var onSelect: ((Int) -> Void)?
///
private(set) var selectedIndex = 0
init(titles: [String]) {
super.init(frame: .zero)
backgroundColor = .white
stackView.axis = .horizontal
stackView.distribution = .fillEqually
titles.enumerated().forEach { index, title in
let button = UIButton(type: .system)
button.tag = index
button.setTitle(title, for: .normal)
button.titleLabel?.font = ScenicQueueTokens.tabFont
button.addTarget(self, action: #selector(buttonTapped(_:)), for: .touchUpInside)
buttons.append(button)
stackView.addArrangedSubview(button)
}
indicator.backgroundColor = ScenicQueueTokens.bannerBlue
divider.backgroundColor = ScenicQueueTokens.cardOutline
addSubview(stackView)
addSubview(divider)
addSubview(indicator)
stackView.snp.makeConstraints { $0.edges.equalToSuperview() }
divider.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(1 / UIScreen.main.scale)
}
setSelectedIndex(0, animated: false)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
updateIndicatorFrame()
}
/// 3pt
func setSelectedIndex(_ index: Int, animated: Bool) {
guard buttons.indices.contains(index) else { return }
selectedIndex = index
buttons.enumerated().forEach { item in
item.element.setTitleColor(item.offset == index ? ScenicQueueTokens.bannerBlue : AppColor.textSecondary, for: .normal)
item.element.titleLabel?.font = item.offset == index ? ScenicQueueTokens.tabSelectedFont : ScenicQueueTokens.tabFont
}
let changes = { self.updateIndicatorFrame() }
animated ? UIView.animate(withDuration: 0.22, animations: changes) : changes()
}
private func updateIndicatorFrame() {
guard !buttons.isEmpty, bounds.width > 0 else { return }
let width = bounds.width / CGFloat(buttons.count)
indicator.frame = CGRect(x: width * CGFloat(selectedIndex), y: bounds.height - 3, width: width, height: 3)
}
@objc private func buttonTapped(_ sender: UIButton) {
setSelectedIndex(sender.tag, animated: true)
onSelect?(sender.tag)
}
}
///
struct ScenicQueueDialogConfiguration {
let title: String
let message: String?
let emphasizedText: String?
let dangerText: String?
let cancelTitle: String
let confirmTitle: String
let buttonRadius: CGFloat
let dismissOnBackdrop: Bool
}
/// Android
final class ScenicQueueConfirmationDialogViewController: UIViewController, UIGestureRecognizerDelegate {
private let configuration: ScenicQueueDialogConfiguration
private let onCancel: () -> Void
private let onConfirm: () -> Void
init(configuration: ScenicQueueDialogConfiguration, onCancel: @escaping () -> Void, onConfirm: @escaping () -> Void) {
self.configuration = configuration
self.onCancel = onCancel
self.onConfirm = onConfirm
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.black.withAlphaComponent(0.42)
if configuration.dismissOnBackdrop {
let backdropTap = UITapGestureRecognizer(target: self, action: #selector(cancelTapped))
backdropTap.delegate = self
view.addGestureRecognizer(backdropTap)
}
let card = UIView()
card.backgroundColor = .white
card.layer.cornerRadius = ScenicQueueTokens.radiusDialog
card.layer.shadowColor = UIColor.black.cgColor
card.layer.shadowOpacity = 0.18
card.layer.shadowRadius = 8
let titleLabel = UILabel()
titleLabel.text = configuration.title
titleLabel.font = ScenicQueueTokens.dialogTitleFont
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
let contentStack = UIStackView(arrangedSubviews: [titleLabel])
contentStack.axis = .vertical
contentStack.spacing = 10
if let message = configuration.message {
let label = makeLabel(message, font: .systemFont(ofSize: 15), color: AppColor.textSecondary)
contentStack.addArrangedSubview(label)
}
if let danger = configuration.dangerText {
let label = makeLabel(danger, font: .systemFont(ofSize: 13), color: ScenicQueueTokens.dialogDangerLine)
contentStack.addArrangedSubview(label)
}
if let emphasized = configuration.emphasizedText {
let label = makeLabel(emphasized, font: ScenicQueueTokens.dialogTicketFont, color: AppColor.textPrimary)
contentStack.addArrangedSubview(label)
contentStack.setCustomSpacing(20, after: label)
}
let cancel = makeButton(configuration.cancelTitle, background: ScenicQueueTokens.dialogCancelBackground, foreground: ScenicQueueTokens.bannerBlue)
cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
let confirm = makeButton(configuration.confirmTitle, background: ScenicQueueTokens.bannerBlue, foreground: .white)
confirm.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
cancel.layer.cornerRadius = configuration.buttonRadius
confirm.layer.cornerRadius = configuration.buttonRadius
let buttons = UIStackView(arrangedSubviews: [cancel, confirm])
buttons.axis = .horizontal
buttons.distribution = .fillEqually
buttons.spacing = 12
contentStack.addArrangedSubview(buttons)
view.addSubview(card)
card.addSubview(contentStack)
card.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(32)
}
contentStack.snp.makeConstraints { $0.edges.equalToSuperview().inset(22) }
buttons.snp.makeConstraints { $0.height.equalTo(44) }
}
private func makeLabel(_ text: String, font: UIFont, color: UIColor) -> UILabel {
let label = UILabel()
label.text = text
label.font = font
label.textColor = color
label.textAlignment = .center
label.numberOfLines = 0
return label
}
private func makeButton(_ title: String, background: UIColor, foreground: UIColor) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.setTitleColor(foreground, for: .normal)
button.titleLabel?.font = ScenicQueueTokens.dialogButtonFont
button.backgroundColor = background
button.clipsToBounds = true
return button
}
@objc private func cancelTapped() {
dismiss(animated: true, completion: onCancel)
}
@objc private func confirmTapped() {
dismiss(animated: true, completion: onConfirm)
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
touch.view === view
}
}
/// Android
final class ScenicQueueDialSheetViewController: UIViewController, UIGestureRecognizerDelegate {
private let digits: String
init(digits: String) {
self.digits = digits
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.black.withAlphaComponent(0.36)
let backdropTap = UITapGestureRecognizer(target: self, action: #selector(cancelTapped))
backdropTap.delegate = self
view.addGestureRecognizer(backdropTap)
let panel = UIView()
panel.backgroundColor = .white
panel.layer.cornerRadius = 18
panel.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
let title = UILabel()
title.text = "拨打电话"
title.font = ScenicQueueTokens.dialogTitleFont
title.textAlignment = .center
let number = UILabel()
number.text = digits
number.font = .systemFont(ofSize: 18, weight: .medium)
number.textColor = AppColor.textPrimary
number.textAlignment = .center
let cancel = pillButton("取消", background: ScenicQueueTokens.dialogCancelBackground, foreground: ScenicQueueTokens.bannerBlue)
cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
let dial = pillButton("拨打", background: ScenicQueueTokens.bannerBlue, foreground: .white)
dial.addTarget(self, action: #selector(dialTapped), for: .touchUpInside)
let buttons = UIStackView(arrangedSubviews: [cancel, dial])
buttons.axis = .horizontal
buttons.distribution = .fillEqually
buttons.spacing = 12
view.addSubview(panel)
[title, number, buttons].forEach(panel.addSubview)
panel.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() }
title.snp.makeConstraints { make in make.top.equalToSuperview().offset(18); make.centerX.equalToSuperview() }
number.snp.makeConstraints { make in make.top.equalTo(title.snp.bottom).offset(18); make.leading.trailing.equalToSuperview().inset(16) }
buttons.snp.makeConstraints { make in
make.top.equalTo(number.snp.bottom).offset(18)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(44)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
}
private func pillButton(_ title: String, background: UIColor, foreground: UIColor) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.setTitleColor(foreground, for: .normal)
button.titleLabel?.font = ScenicQueueTokens.dialogButtonFont
button.backgroundColor = background
button.layer.cornerRadius = 22
return button
}
@objc private func cancelTapped() { dismiss(animated: true) }
@objc private func dialTapped() {
dismiss(animated: true) {
guard let url = URL(string: "tel:\(self.digits)") else { return }
UIApplication.shared.open(url)
}
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
touch.view === view
}
}
/// Android
final class ScenicQueueMarkDialogViewController: UIViewController {
private let pending: ScenicQueuePendingMark
private let onCancel: () -> Void
private let onConfirm: (Bool, Int) -> Void
private let markSwitch = UISwitch()
private let daysLabel = UILabel()
private var days = 0 { didSet { daysLabel.text = "\(days)" } }
init(pending: ScenicQueuePendingMark, onCancel: @escaping () -> Void, onConfirm: @escaping (Bool, Int) -> Void) {
self.pending = pending
self.onCancel = onCancel
self.onConfirm = onConfirm
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.black.withAlphaComponent(0.42)
let card = UIView()
card.backgroundColor = .white
card.layer.cornerRadius = ScenicQueueTokens.markDialogRadius
let title = UILabel()
title.text = "标记用户"
title.font = ScenicQueueTokens.dialogTitleFont
title.textAlignment = .center
let queue = UILabel()
queue.text = pending.queueNo
queue.font = .systemFont(ofSize: 24, weight: .bold)
queue.textColor = ScenicQueueTokens.bannerBlue
let phone = UILabel()
phone.text = pending.phoneMasked
phone.font = ScenicQueueTokens.phoneMaskedFont
phone.textColor = AppColor.textSecondary
let identityTitle = UILabel()
identityTitle.text = "标记为打野摄影师"
identityTitle.font = ScenicQueueTokens.settingsRowTitleFont
markSwitch.isOn = pending.markAsFreelancePhotog == 1
markSwitch.onTintColor = ScenicQueueTokens.bannerBlue
let identityRow = UIStackView(arrangedSubviews: [identityTitle, UIView(), markSwitch])
identityRow.axis = .horizontal
identityRow.alignment = .center
let hint = UILabel()
hint.text = "标记后,该用户扫码时将受排队限制。"
hint.font = ScenicQueueTokens.settingsHintFont
hint.textColor = AppColor.textTertiary
hint.numberOfLines = 0
let daysTitle = UILabel()
daysTitle.text = "限制排队天数"
daysTitle.font = ScenicQueueTokens.settingsRowTitleFont
let minus = stepButton("", action: #selector(minusTapped))
let plus = stepButton("+", action: #selector(plusTapped))
daysLabel.text = "0"
daysLabel.font = .systemFont(ofSize: 16, weight: .medium)
daysLabel.textAlignment = .center
let stepper = UIStackView(arrangedSubviews: [minus, daysLabel, plus])
stepper.axis = .horizontal
stepper.alignment = .center
stepper.distribution = .fillEqually
stepper.backgroundColor = ScenicQueueTokens.markDialogStepperBackground
stepper.layer.cornerRadius = 10
let cancel = actionButton("取消", background: ScenicQueueTokens.markDialogCancelBackground, foreground: ScenicQueueTokens.bannerBlue)
cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
let confirm = actionButton("完成", background: ScenicQueueTokens.bannerBlue, foreground: .white)
confirm.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
let buttons = UIStackView(arrangedSubviews: [cancel, confirm])
buttons.axis = .horizontal
buttons.distribution = .fillEqually
buttons.spacing = 12
let stack = UIStackView(arrangedSubviews: [title, queue, phone, identityRow, hint, daysTitle, stepper, buttons])
stack.axis = .vertical
stack.spacing = 12
view.addSubview(card)
card.addSubview(stack)
card.snp.makeConstraints { make in make.center.equalToSuperview(); make.width.equalToSuperview().multipliedBy(0.88) }
stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(22) }
stepper.snp.makeConstraints { $0.height.equalTo(44) }
buttons.snp.makeConstraints { $0.height.equalTo(44) }
}
private func stepButton(_ title: String, action: Selector) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.setTitleColor(ScenicQueueTokens.bannerBlue, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 22, weight: .medium)
button.addTarget(self, action: action, for: .touchUpInside)
return button
}
private func actionButton(_ title: String, background: UIColor, foreground: UIColor) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.setTitleColor(foreground, for: .normal)
button.titleLabel?.font = ScenicQueueTokens.dialogButtonFont
button.backgroundColor = background
button.layer.cornerRadius = ScenicQueueTokens.markDialogButtonRadius
return button
}
@objc private func minusTapped() { days = max(0, days - 1) }
@objc private func plusTapped() { days = min(999, days + 1) }
@objc private func cancelTapped() { dismiss(animated: true, completion: onCancel) }
@objc private func confirmTapped() {
let marked = markSwitch.isOn
dismiss(animated: true) { self.onConfirm(marked, marked ? self.days : 0) }
}
}