新增景区排队管理功能

This commit is contained in:
2026-07-07 15:32:25 +08:00
parent 854a66689f
commit 0aa8b14e1f
20 changed files with 4642 additions and 1 deletions

View File

@ -0,0 +1,427 @@
//
// ScenicQueueSettingsViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android `ScenicQueueSettingsScreen` Tab
final class ScenicQueueSettingsViewController: BaseViewController {
private let viewModel = ScenicQueueSettingsViewModel()
private let api: ScenicQueueAPIProtocol
private let segmentedControl = UISegmentedControl(items: ScenicQueueSettingsTab.allCases.map(\.title))
private let scrollView = UIScrollView()
private let stackView = UIStackView()
private let saveBar = UIView()
private let saveButton = UIButton(type: .system)
private var fieldRows: [String: ScenicQueueSettingFieldRow] = [:]
private var switchRows: [String: ScenicQueueSwitchRow] = [:]
@MainActor
init(api: ScenicQueueAPIProtocol? = nil) {
self.api = api ?? NetworkServices.shared.scenicQueueAPI
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "设置"
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "配置日志",
style: .plain,
target: self,
action: #selector(logTapped)
)
}
override func setupUI() {
view.backgroundColor = ScenicQueueTokens.pageBackground
segmentedControl.selectedSegmentIndex = viewModel.settingsTab.rawValue
segmentedControl.selectedSegmentTintColor = .white
segmentedControl.setTitleTextAttributes([
.font: ScenicQueueTokens.settingsRowTitleFont,
.foregroundColor: AppColor.textSecondary,
], for: .normal)
segmentedControl.setTitleTextAttributes([
.font: UIFont.systemFont(ofSize: 14, weight: .semibold),
.foregroundColor: ScenicQueueTokens.bannerBlue,
], for: .selected)
stackView.axis = .vertical
stackView.spacing = 14
scrollView.keyboardDismissMode = .onDrag
scrollView.alwaysBounceVertical = true
saveBar.backgroundColor = .white
saveButton.setTitle("保存", for: .normal)
saveButton.setTitleColor(.white, for: .normal)
saveButton.titleLabel?.font = ScenicQueueTokens.settingsSaveButtonFont
saveButton.backgroundColor = ScenicQueueTokens.bannerBlue
saveButton.layer.cornerRadius = ScenicQueueTokens.bottomSaveButtonRadius
saveButton.clipsToBounds = true
view.addSubview(segmentedControl)
view.addSubview(scrollView)
scrollView.addSubview(stackView)
view.addSubview(saveBar)
saveBar.addSubview(saveButton)
rebuildForm()
}
override func setupConstraints() {
segmentedControl.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(8)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(40)
}
saveBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
saveButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(31)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
make.height.equalTo(48)
}
scrollView.snp.makeConstraints { make in
make.top.equalTo(segmentedControl.snp.bottom).offset(10)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(saveBar.snp.top)
}
stackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 16, right: 16))
make.width.equalToSuperview().offset(-32)
}
}
override func bindActions() {
segmentedControl.addTarget(self, action: #selector(tabChanged), for: .valueChanged)
saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside)
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyState() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
viewModel.onNavigateBack = { [weak self] in
Task { @MainActor in self?.navigationController?.popViewController(animated: true) }
}
viewModel.onNavigateLog = { [weak self] in
Task { @MainActor in self?.navigationController?.pushViewController(ScenicQueueSettingChangeLogViewController(), animated: true) }
}
}
private func applyState() {
segmentedControl.selectedSegmentIndex = viewModel.settingsTab.rawValue
rebuildForm()
}
private func rebuildForm() {
stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
fieldRows.removeAll()
switchRows.removeAll()
switch viewModel.settingsTab {
case .basic:
buildBasicForm()
case .notify:
buildNotifyForm()
case .voice:
buildVoiceForm()
}
}
private func buildBasicForm() {
stackView.addArrangedSubview(tipCard())
stackView.addArrangedSubview(actionCard(title: "打卡点", value: viewModel.selectedPunchSpotDisplayText, primary: "选择打卡点", secondary: "保存小程序码", primaryAction: #selector(punchSpotTapped), secondaryAction: #selector(saveQRTapped), invalid: viewModel.highlightedInvalidFieldKeys.contains("punchSpot")))
addSwitchRow(key: "businessOpen", title: "营业状态", isOn: viewModel.businessOpen, action: #selector(businessOpenChanged(_:)))
addTextRow(key: "businessStart", title: "营业开始时间", value: viewModel.businessStartTime, suffix: "HH:mm", keyboard: .numbersAndPunctuation, action: #selector(businessStartChanged(_:)))
addTextRow(key: "businessEnd", title: "营业结束时间", value: viewModel.businessEndTime, suffix: "HH:mm", keyboard: .numbersAndPunctuation, action: #selector(businessEndChanged(_:)))
addTextRow(key: "queueDistance", title: "允许排队距离", value: "\(viewModel.queueDistanceMeter)", suffix: "", keyboard: .numberPad, action: #selector(queueDistanceChanged(_:)))
addTextRow(key: "shootMinute", title: "拍摄时长(分钟)", value: "\(viewModel.shootMinute)", suffix: "", keyboard: .numberPad, action: #selector(shootMinuteChanged(_:)))
addTextRow(key: "shootSecond", title: "拍摄时长(秒)", value: "\(viewModel.shootSecond)", suffix: "", keyboard: .numberPad, action: #selector(shootSecondChanged(_:)))
addTextRow(key: "queueTakeLimit", title: "单人取号次数", value: "\(viewModel.queueTakeLimit)", suffix: "", keyboard: .numberPad, action: #selector(queueTakeLimitChanged(_:)))
addTextRow(key: "missOffset", title: "过号重排顺延位数", value: "\(viewModel.missCallRequeueOffset)", suffix: "", keyboard: .numberPad, action: #selector(missOffsetChanged(_:)))
addSwitchRow(key: "showShoot", title: "展示开始拍摄按钮", isOn: viewModel.showStartShootingButton, action: #selector(showShootChanged(_:)))
addTextRow(key: "autoCall", title: "自动提前叫号数量", value: "\(viewModel.autoCallAheadCount)", suffix: "", keyboard: .numberPad, action: #selector(autoCallChanged(_:)))
addSwitchRow(key: "quickCall", title: "底部快速叫号", isOn: viewModel.quickCallButtonEnabled, action: #selector(quickCallChanged(_:)))
addSwitchRow(key: "prepareCall", title: "底部准备叫号", isOn: viewModel.prepareCallButtonEnabled, action: #selector(prepareCallChanged(_:)))
}
private func buildNotifyForm() {
addTextRow(key: "firstAhead", title: "第一次提前通知阈值", value: "\(viewModel.firstAheadCount)", suffix: "", keyboard: .numberPad, action: #selector(firstAheadChanged(_:)))
addSwitchRow(key: "firstSms", title: "第一次短信通知", isOn: viewModel.firstSms, action: #selector(firstSmsChanged(_:)))
addSwitchRow(key: "firstPhone", title: "第一次电话通知", isOn: viewModel.firstPhone, action: #selector(firstPhoneChanged(_:)))
addTextRow(key: "secondAhead", title: "第二次提前通知阈值", value: "\(viewModel.secondAheadCount)", suffix: "", keyboard: .numberPad, action: #selector(secondAheadChanged(_:)))
addSwitchRow(key: "secondSms", title: "第二次短信通知", isOn: viewModel.secondSms, action: #selector(secondSmsChanged(_:)))
addSwitchRow(key: "secondPhone", title: "第二次电话通知", isOn: viewModel.secondPhone, action: #selector(secondPhoneChanged(_:)))
}
private func buildVoiceForm() {
addTextRow(key: "broadcastInterval", title: "播报间隔时间", value: "\(viewModel.broadcastIntervalSec)", suffix: "", keyboard: .numberPad, action: #selector(broadcastChanged(_:)))
addTextRow(key: "countdown", title: "进入读秒倒计时阈值", value: "\(viewModel.countdownThresholdSec)", suffix: "", keyboard: .numberPad, action: #selector(countdownChanged(_:)))
stackView.addArrangedSubview(actionCard(title: "蓝牙音响测试", value: "请确认外放音量是否合适", primary: "播放测试音", secondary: nil, primaryAction: #selector(testSoundTapped), secondaryAction: nil))
addTextRow(key: "customTTS", title: "自定义播报文案", value: viewModel.customTTSText, suffix: "", keyboard: .default, action: #selector(customTTSChanged(_:)))
stackView.addArrangedSubview(actionCard(title: "播报文案", value: "最多保存5个语音", primary: "播放文本", secondary: "保存到预设", primaryAction: #selector(playCustomTapped), secondaryAction: #selector(savePresetTapped)))
for (index, phrase) in viewModel.presetVoices.enumerated() {
stackView.addArrangedSubview(presetVoiceCard(index: index, phrase: phrase))
}
}
private func addTextRow(key: String, title: String, value: String, suffix: String, keyboard: UIKeyboardType, action: Selector) {
let row = ScenicQueueSettingFieldRow(title: title, value: value, suffix: suffix, keyboard: keyboard)
row.textField.addTarget(self, action: action, for: .editingChanged)
fieldRows[key] = row
stackView.addArrangedSubview(row)
}
private func addSwitchRow(key: String, title: String, isOn: Bool, action: Selector) {
let row = ScenicQueueSwitchRow(title: title, isOn: isOn)
row.toggle.addTarget(self, action: action, for: .valueChanged)
switchRows[key] = row
stackView.addArrangedSubview(row)
}
private func tipCard() -> UIView {
let card = ScenicQueueSettingCard()
card.backgroundColor = ScenicQueueTokens.bannerBlue
let title = UILabel()
title.text = "排队基础参数"
title.textColor = .white
title.font = ScenicQueueTokens.settingsSectionTitleFont
let body = UILabel()
body.text = "选择打卡点后保存配置,顾客即可扫码取号排队。"
body.textColor = UIColor.white.withAlphaComponent(0.86)
body.font = ScenicQueueTokens.settingsHintFont
body.numberOfLines = 0
let stack = UIStackView(arrangedSubviews: [title, body])
stack.axis = .vertical
stack.spacing = 6
card.addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(14)
}
return card
}
private func actionCard(title: String, value: String, primary: String, secondary: String?, primaryAction: Selector, secondaryAction: Selector?, invalid: Bool = false) -> UIView {
let card = ScenicQueueSettingCard()
card.layer.borderColor = invalid ? AppColor.danger.cgColor : ScenicQueueTokens.cardOutline.cgColor
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.font = ScenicQueueTokens.settingsFieldLabelFont
titleLabel.textColor = AppColor.textPrimary
let valueLabel = UILabel()
valueLabel.text = value
valueLabel.font = ScenicQueueTokens.settingsHintFont
valueLabel.textColor = AppColor.textSecondary
valueLabel.numberOfLines = 0
let primaryButton = UIButton(type: .system)
primaryButton.setTitle(primary, for: .normal)
primaryButton.titleLabel?.font = ScenicQueueTokens.buttonSecondaryFont
primaryButton.addTarget(self, action: primaryAction, for: .touchUpInside)
let buttons = UIStackView(arrangedSubviews: [primaryButton])
buttons.axis = .horizontal
buttons.spacing = 12
if let secondary, let secondaryAction {
let secondaryButton = UIButton(type: .system)
secondaryButton.setTitle(secondary, for: .normal)
secondaryButton.titleLabel?.font = ScenicQueueTokens.buttonSecondaryFont
secondaryButton.addTarget(self, action: secondaryAction, for: .touchUpInside)
buttons.addArrangedSubview(secondaryButton)
}
let stack = UIStackView(arrangedSubviews: [titleLabel, valueLabel, buttons])
stack.axis = .vertical
stack.spacing = 8
card.addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(14)
}
return card
}
private func presetVoiceCard(index: Int, phrase: String) -> UIView {
let card = ScenicQueueSettingCard()
let label = UILabel()
label.text = phrase
label.font = ScenicQueueTokens.settingsRowTitleFont
label.textColor = AppColor.textPrimary
label.numberOfLines = 0
let delete = UIButton(type: .system)
delete.setImage(UIImage(systemName: "trash"), for: .normal)
delete.tintColor = AppColor.danger
delete.tag = index
delete.addTarget(self, action: #selector(deletePresetTapped(_:)), for: .touchUpInside)
card.addSubview(label)
card.addSubview(delete)
label.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(14)
make.trailing.equalTo(delete.snp.leading).offset(-12)
}
delete.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-14)
make.centerY.equalToSuperview()
make.size.equalTo(44)
}
return card
}
@objc private func tabChanged() {
viewModel.setSettingsTab(ScenicQueueSettingsTab(rawValue: segmentedControl.selectedSegmentIndex) ?? .basic)
}
@objc private func logTapped() { viewModel.openSettingChangeLog() }
@objc private func saveTapped() {
view.endEditing(true)
Task { await viewModel.save(api: api) }
}
@objc private func punchSpotTapped() {
Task {
await viewModel.loadPunchSpotOptions(api: api)
await MainActor.run { presentPunchSpotPicker() }
}
}
private func presentPunchSpotPicker() {
let alert = UIAlertController(title: "选择打卡点", message: nil, preferredStyle: .actionSheet)
viewModel.punchSpotOptions.forEach { option in
alert.addAction(UIAlertAction(title: option.label, style: .default) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.setPunchSpot(option, api: self.api) }
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
@objc private func saveQRTapped() { Task { await viewModel.saveShootQueueQRCode(api: api) } }
@objc private func testSoundTapped() { viewModel.playBluetoothTestSound() }
@objc private func playCustomTapped() { viewModel.toggleCustomTTSPlayback() }
@objc private func savePresetTapped() { viewModel.saveCurrentTextAsPresetVoiceLocal() }
@objc private func deletePresetTapped(_ sender: UIButton) { viewModel.deletePresetVoice(at: sender.tag) }
@objc private func businessOpenChanged(_ sender: UISwitch) { viewModel.toggleBusinessOpen() }
@objc private func showShootChanged(_ sender: UISwitch) { viewModel.toggleShowStartShootingButton() }
@objc private func quickCallChanged(_ sender: UISwitch) { viewModel.toggleQuickCallButtonEnabled() }
@objc private func prepareCallChanged(_ sender: UISwitch) { viewModel.togglePrepareCallButtonEnabled() }
@objc private func firstSmsChanged(_ sender: UISwitch) { viewModel.toggleFirstSms() }
@objc private func firstPhoneChanged(_ sender: UISwitch) { viewModel.toggleFirstPhone() }
@objc private func secondSmsChanged(_ sender: UISwitch) { viewModel.toggleSecondSms() }
@objc private func secondPhoneChanged(_ sender: UISwitch) { viewModel.toggleSecondPhone() }
@objc private func businessStartChanged(_ sender: UITextField) { viewModel.setBusinessStartTime(sender.text ?? "") }
@objc private func businessEndChanged(_ sender: UITextField) { viewModel.setBusinessEndTime(sender.text ?? "") }
@objc private func queueDistanceChanged(_ sender: UITextField) { viewModel.setQueueDistanceMeter(Int(sender.text ?? "") ?? 0) }
@objc private func shootMinuteChanged(_ sender: UITextField) { viewModel.setShootMinute(Int(sender.text ?? "") ?? 0) }
@objc private func shootSecondChanged(_ sender: UITextField) { viewModel.setShootSecond(Int(sender.text ?? "") ?? 0) }
@objc private func queueTakeLimitChanged(_ sender: UITextField) { viewModel.setQueueTakeLimit(Int(sender.text ?? "") ?? 0) }
@objc private func missOffsetChanged(_ sender: UITextField) { viewModel.setMissCallRequeueOffset(Int(sender.text ?? "") ?? 1) }
@objc private func autoCallChanged(_ sender: UITextField) { viewModel.setAutoCallAheadCount(Int(sender.text ?? "") ?? 0) }
@objc private func firstAheadChanged(_ sender: UITextField) { viewModel.setFirstAheadCount(Int(sender.text ?? "") ?? 0) }
@objc private func secondAheadChanged(_ sender: UITextField) { viewModel.setSecondAheadCount(Int(sender.text ?? "") ?? 0) }
@objc private func broadcastChanged(_ sender: UITextField) { viewModel.setBroadcastIntervalSec(Int(sender.text ?? "") ?? 50) }
@objc private func countdownChanged(_ sender: UITextField) { viewModel.setCountdownThresholdSec(Int(sender.text ?? "") ?? 15) }
@objc private func customTTSChanged(_ sender: UITextField) { viewModel.setCustomTTSText(sender.text ?? "") }
}
///
class ScenicQueueSettingCard: UIView {
init() {
super.init(frame: .zero)
backgroundColor = .white
layer.cornerRadius = ScenicQueueTokens.radiusCard
layer.borderWidth = 1
layer.borderColor = ScenicQueueTokens.cardOutline.cgColor
clipsToBounds = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
final class ScenicQueueSettingFieldRow: ScenicQueueSettingCard {
let textField = UITextField()
init(title: String, value: String, suffix: String, keyboard: UIKeyboardType) {
super.init()
let label = UILabel()
label.text = title
label.font = ScenicQueueTokens.settingsFieldLabelFont
label.textColor = AppColor.textPrimary
textField.text = value
textField.keyboardType = keyboard
textField.textAlignment = .right
textField.font = ScenicQueueTokens.settingsRowTitleFont
textField.textColor = AppColor.textPrimary
let suffixLabel = UILabel()
suffixLabel.text = suffix
suffixLabel.font = ScenicQueueTokens.settingsHintFont
suffixLabel.textColor = AppColor.textSecondary
addSubview(label)
addSubview(textField)
addSubview(suffixLabel)
label.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(14)
make.centerY.equalToSuperview()
}
suffixLabel.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-14)
make.centerY.equalToSuperview()
}
textField.snp.makeConstraints { make in
make.leading.greaterThanOrEqualTo(label.snp.trailing).offset(12)
make.trailing.equalTo(suffixLabel.snp.leading).offset(-6)
make.top.bottom.equalToSuperview().inset(8)
make.height.equalTo(38)
make.width.greaterThanOrEqualTo(90)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
final class ScenicQueueSwitchRow: ScenicQueueSettingCard {
let toggle = UISwitch()
init(title: String, isOn: Bool) {
super.init()
let label = UILabel()
label.text = title
label.font = ScenicQueueTokens.settingsFieldLabelFont
label.textColor = AppColor.textPrimary
toggle.isOn = isOn
toggle.onTintColor = ScenicQueueTokens.bannerBlue
addSubview(label)
addSubview(toggle)
label.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(14)
make.centerY.equalToSuperview()
make.top.bottom.equalToSuperview().inset(14)
}
toggle.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-14)
make.centerY.equalToSuperview()
make.leading.greaterThanOrEqualTo(label.snp.trailing).offset(12)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}