// // ScenicQueueSettingsViewController.swift // suixinkan // import SnapKit import UIKit /// 排队设置页,按 Android `ScenicQueueSettingsScreen` 提供三页持久化表单。 final class ScenicQueueSettingsViewController: BaseViewController { private let viewModel: ScenicQueueSettingsViewModel private let api: ScenicQueueAPIProtocol private let tabStrip = ScenicQueueTabStripView( titles: ScenicQueueSettingsTab.allCases.map(\.title)) private let pageController = UIPageViewController( transitionStyle: .scroll, navigationOrientation: .horizontal) private let saveBar = UIView() private let saveButton = UIButton(type: .system) private var pages: [ScenicQueueSettingsPageController] = [] private var numericFields: [String: ScenicQueueNumericFieldView] = [:] private var switches: [String: ScenicQueueSwitchRow] = [:] private let punchSpotButton = UIButton(type: .system) private var selectedPunchLabel = UILabel() private var punchSpotOptionsLoading = false private var punchSpotOptionsLoaded = false private var startTimeButton = UIButton(type: .system) private var endTimeButton = UIButton(type: .system) private var customTextView = UITextView() private var customPlayButton = UIButton(type: .system) private var presetStack = UIStackView() private var presetSummaryLabel = UILabel() @MainActor init( api: ScenicQueueAPIProtocol? = nil, viewModel: ScenicQueueSettingsViewModel? = nil ) { self.api = api ?? NetworkServices.shared.scenicQueueAPI self.viewModel = viewModel ?? ScenicQueueSettingsViewModel() 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 pageController.dataSource = self pageController.delegate = self addChild(pageController) view.addSubview(tabStrip) view.addSubview(pageController.view) pageController.didMove(toParent: self) pages = ScenicQueueSettingsTab.allCases.map { ScenicQueueSettingsPageController(tab: $0) } buildBasicPage(pages[0]) buildNotifyPage(pages[1]) buildVoicePage(pages[2]) pageController.setViewControllers([pages[0]], direction: .forward, animated: false) saveBar.backgroundColor = .white saveBar.layer.shadowColor = UIColor.black.cgColor saveBar.layer.shadowOpacity = 0.08 saveBar.layer.shadowRadius = 6 saveBar.layer.shadowOffset = CGSize(width: 0, height: -2) saveButton.setTitle("保存", for: .normal) saveButton.setTitleColor(.white, for: .normal) saveButton.titleLabel?.font = ScenicQueueTokens.settingsSaveButtonFont saveButton.backgroundColor = ScenicQueueTokens.bannerBlue saveButton.layer.cornerRadius = ScenicQueueTokens.bottomSaveButtonRadius saveBar.addSubview(saveButton) view.addSubview(saveBar) applyState() } override func setupConstraints() { tabStrip.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide) make.leading.trailing.equalToSuperview() make.height.equalTo(48) } 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.height.equalTo(48) make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) } pageController.view.snp.makeConstraints { make in make.top.equalTo(tabStrip.snp.bottom) make.leading.trailing.equalToSuperview() make.bottom.equalTo(saveBar.snp.top) } } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) loadPunchSpotMenuIfNeeded() } override func bindActions() { tabStrip.onSelect = { [weak self] index in self?.selectPage(index, animated: true) } 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 selectPage(_ index: Int, animated: Bool) { guard pages.indices.contains(index) else { return } let current = viewModel.settingsTab.rawValue let direction: UIPageViewController.NavigationDirection = index >= current ? .forward : .reverse viewModel.setSettingsTab(ScenicQueueSettingsTab(rawValue: index) ?? .basic) pageController.setViewControllers([pages[index]], direction: direction, animated: animated) tabStrip.setSelectedIndex(index, animated: animated) } private func applyState() { let selected = viewModel.settingsTab.rawValue tabStrip.setSelectedIndex(selected, animated: true) if pageController.viewControllers?.first !== pages[selected] { pageController.setViewControllers([pages[selected]], direction: .forward, animated: false) } selectedPunchLabel.text = viewModel.selectedPunchSpotDisplayText configurePunchSpotMenu() startTimeButton.setTitle(viewModel.businessStartTime, for: .normal) endTimeButton.setTitle(viewModel.businessEndTime, for: .normal) updateField( "queueDistanceMeter", ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: viewModel.queueDistanceMeter)) updateField("shootMinute", "\(viewModel.shootMinute)") updateField("shootSecond", "\(viewModel.shootSecond)") updateField("queueTakeLimit", "\(viewModel.queueTakeLimit)") updateField("missCallRequeueOffset", "\(viewModel.missCallRequeueOffset)") updateField("autoCallAheadCount", "\(viewModel.autoCallAheadCount)") updateField("firstNoticeAhead", "\(viewModel.firstAheadCount)") updateField("secondNoticeAhead", "\(viewModel.secondAheadCount)") updateField("broadcastIntervalSec", "\(viewModel.broadcastIntervalSec)") updateField("countdownReadableThresholdSec", "\(viewModel.countdownThresholdSec)") setSwitch("businessOpen", viewModel.businessOpen) setSwitch("showShoot", viewModel.showStartShootingButton) setSwitch("quickCall", viewModel.quickCallButtonEnabled) setSwitch("prepareCall", viewModel.prepareCallButtonEnabled) setSwitch("firstSms", viewModel.firstSms) setSwitch("firstPhone", viewModel.firstPhone) setSwitch("secondSms", viewModel.secondSms) setSwitch("secondPhone", viewModel.secondPhone) if !customTextView.isFirstResponder { customTextView.text = viewModel.customTTSText } customTextView.isEditable = !viewModel.isCustomTTSLooping customPlayButton.setTitle(viewModel.isCustomTTSLooping ? "停止播放" : "试听", for: .normal) rebuildPresetRows() viewModel.highlightedInvalidFieldKeys.forEach { numericFields[$0]?.setInvalid(true) } } private func updateField(_ key: String, _ value: String) { let field = numericFields[key] field?.setInvalid(viewModel.highlightedInvalidFieldKeys.contains(key)) field?.setDisplayValue(value) } private func setSwitch(_ key: String, _ value: Bool) { switches[key]?.setValue(value) } private func buildBasicPage(_ page: ScenicQueueSettingsPageController) { page.stack.addArrangedSubview(tipCard()) page.stack.addArrangedSubview(punchSpotCard()) page.stack.addArrangedSubview(businessCard()) page.stack.addArrangedSubview( sectionCard(title: "排队距离限制") { let hint = self.hintLabel("顾客仅可在该距离范围内取号,0 表示不限制。合法范围 0~9999.99 千米。") let field = self.numericField(key: "queueDistanceMeter", unit: "千米", keyboard: .decimalPad) return [hint, field] }) page.stack.addArrangedSubview( sectionCard(title: "预计拍摄时长") { let minute = self.numericField(key: "shootMinute", unit: "分") let second = self.numericField(key: "shootSecond", unit: "秒") let row = UIStackView(arrangedSubviews: [minute, second]) row.axis = .horizontal row.spacing = 12 row.distribution = .fillEqually return [self.hintLabel("总时长必须大于 0;分钟 0~30,秒 0~59。"), row] }) page.stack.addArrangedSubview( sectionCard(title: "单人取号次数限制") { [ self.hintLabel("0 表示不限制,最大 999999 次。"), self.numericField(key: "queueTakeLimit", unit: "次"), ] }) page.stack.addArrangedSubview( sectionCard(title: "过号重新排队") { [ self.hintLabel("设置过号重新排队时相对队首顺延的位数,范围 1~9999。"), self.numericField(key: "missCallRequeueOffset", unit: "位"), ] }) page.stack.addArrangedSubview( sectionCard(title: "拍摄与叫号配置", info: true) { let shoot = self.switchRow( key: "showShoot", title: "显示开始拍摄按钮", action: #selector(self.showShootChanged(_:))) let auto = self.numericField(key: "autoCallAheadCount", unit: "人") return [shoot, self.divider(), self.hintLabel("开始拍摄后自动通知后续 0~5 个排队号。"), auto] }) page.stack.addArrangedSubview( sectionCard(title: "底部叫号按钮") { [ self.switchRow( key: "quickCall", title: "显示快速叫号", action: #selector(self.quickCallChanged(_:))), self.divider(), self.switchRow( key: "prepareCall", title: "显示准备叫号", action: #selector(self.prepareCallChanged(_:))), ] }) } private func buildNotifyPage(_ page: ScenicQueueSettingsPageController) { page.stack.addArrangedSubview( sectionCard(title: "提前通知规则", info: true) { [ self.sectionLabel("第一次提前通知"), self.hintLabel("前方人数达到阈值时触发,范围 0~50 位。"), self.numericField(key: "firstNoticeAhead", unit: "位"), self.switchRow( key: "firstSms", title: "短信通知", action: #selector(self.firstSmsChanged(_:))), self.switchRow( key: "firstPhone", title: "电话通知(自动语音)", action: #selector(self.firstPhoneChanged(_:))), self.divider(), self.sectionLabel("第二次提前通知"), self.hintLabel("可设置更接近队首的二次提醒,范围 0~50 位。"), self.numericField(key: "secondNoticeAhead", unit: "位"), self.switchRow( key: "secondSms", title: "短信通知", action: #selector(self.secondSmsChanged(_:))), self.switchRow( key: "secondPhone", title: "电话通知(自动语音)", action: #selector(self.secondPhoneChanged(_:))), ] }) } private func buildVoicePage(_ page: ScenicQueueSettingsPageController) { page.stack.addArrangedSubview( sectionCard(title: "倒计时播报配置", info: true) { let test = self.primaryButton("播放测试声音", action: #selector(self.testSoundTapped)) return [ self.sectionLabel("播报间隔时间"), self.hintLabel("从开拍起按间隔播报剩余时间,合法范围 40~60 秒。"), self.numericField(key: "broadcastIntervalSec", unit: "秒"), self.sectionLabel("进入读秒倒计时阈值"), self.hintLabel("到达阈值后逐秒播报,合法范围 10~30 秒。"), self.numericField(key: "countdownReadableThresholdSec", unit: "秒"), self.sectionLabel("测试蓝牙音响"), test, ] }) page.stack.addArrangedSubview(customTTSCard()) page.stack.addArrangedSubview(presetVoicesCard()) } private func tipCard() -> UIView { let card = ScenicQueueSettingCard() card.backgroundColor = ScenicQueueTokens.bannerBlue card.layer.borderWidth = 0 let title = sectionLabel("温馨提示") title.textColor = .white let body = hintLabel("配置信息修改后,请及时测试叫号设备状态") body.textColor = UIColor.white.withAlphaComponent(0.92) let stack = verticalStack([title, body], spacing: 6) card.addSubview(stack) stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(UIEdgeInsets(top: 20, left: 16, bottom: 20, right: 16)) } return card } private func punchSpotCard() -> UIView { sectionCard(title: "打卡点信息") { let label = self.hintLabel("选择打卡点") let selector = self.punchSpotButton selector.backgroundColor = .white selector.layer.cornerRadius = ScenicQueueTokens.radiusInput selector.layer.borderWidth = 1 selector.layer.borderColor = ScenicQueueTokens.fieldBorderMuted.cgColor selector.showsMenuAsPrimaryAction = true self.selectedPunchLabel.font = ScenicQueueTokens.settingsRowTitleFont self.selectedPunchLabel.textColor = AppColor.textPrimary let arrow = UILabel() arrow.text = "▼" arrow.font = ScenicQueueTokens.settingsHintFont arrow.textColor = AppColor.textTertiary selector.addSubview(self.selectedPunchLabel) selector.addSubview(arrow) self.selectedPunchLabel.snp.makeConstraints { make in make.leading.equalToSuperview().offset(12) make.centerY.equalToSuperview() make.trailing.lessThanOrEqualTo(arrow.snp.leading).offset(-8) } arrow.snp.makeConstraints { make in make.trailing.equalToSuperview().offset(-12) make.centerY.equalToSuperview() } selector.snp.makeConstraints { $0.height.equalTo(52) } let qrLabel = self.sectionLabel("点击保存打卡点排队二维码") let qr = self.compactPrimaryButton("保存二维码", action: #selector(self.saveQRTapped)) let qrRow = UIStackView(arrangedSubviews: [qrLabel, qr]) qrRow.axis = .horizontal qrRow.alignment = .center qrRow.spacing = 10 return [label, selector, qrRow] } } private func businessCard() -> UIView { sectionCard(title: "排队状态与时间", info: true) { let status = self.switchRow( key: "businessOpen", title: "当前排队状态", action: #selector(self.businessOpenChanged(_:))) self.startTimeButton = self.timeButton(action: #selector(self.startTimeTapped)) self.endTimeButton = self.timeButton(action: #selector(self.endTimeTapped)) let times = UIStackView(arrangedSubviews: [ self.labeledControl("营业开始时间", self.startTimeButton), self.labeledControl("营业结束时间", self.endTimeButton), ]) times.axis = .horizontal times.distribution = .fillEqually times.spacing = 12 return [status, self.hintLabel("开启后顾客可在营业时间内扫码取号;关闭后暂停排队。"), self.divider(), times] } } private func customTTSCard() -> UIView { sectionCard(title: "自定义文本语音播报") { self.customTextView.font = ScenicQueueTokens.settingsRowTitleFont self.customTextView.textColor = AppColor.textPrimary self.customTextView.layer.cornerRadius = ScenicQueueTokens.radiusInput self.customTextView.layer.borderWidth = 1 self.customTextView.layer.borderColor = ScenicQueueTokens.fieldBorderMuted.cgColor self.customTextView.delegate = self self.customTextView.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 12, right: 8) self.customTextView.snp.makeConstraints { $0.height.equalTo(104) } self.customPlayButton = self.outlinedButton("试听", action: #selector(self.customPlayTapped)) let save = self.primaryButton("保存", action: #selector(self.savePresetTapped)) let row = UIStackView(arrangedSubviews: [self.customPlayButton, save]) row.axis = .horizontal row.distribution = .fillEqually row.spacing = 12 return [self.customTextView, row] } } private func presetVoicesCard() -> UIView { let card = ScenicQueueSettingCard() presetSummaryLabel.font = ScenicQueueTokens.settingsSectionTitleFont presetSummaryLabel.textColor = AppColor.textPrimary presetStack.axis = .vertical presetStack.spacing = 10 let hint = hintLabel("左滑显示删除;点左侧图标循环播报该条。底部「保存」提交全部排队参数。") let stack = verticalStack([presetSummaryLabel, presetStack, hint], spacing: 14) card.addSubview(stack) stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) } return card } private func rebuildPresetRows() { presetSummaryLabel.text = "已保存的语音(\(viewModel.presetVoices.count)/5)" presetStack.arrangedSubviews.forEach { $0.removeFromSuperview() } guard !viewModel.presetVoices.isEmpty else { presetStack.addArrangedSubview(hintLabel("暂无预设文案,输入后点击「保存」添加到本地列表(最多 5 条)。")) return } for (index, phrase) in viewModel.presetVoices.enumerated() { let playing = viewModel.isCustomTTSLooping && viewModel.ttsLoopAnchorText?.trimmingCharacters(in: .whitespacesAndNewlines) == phrase.trimmingCharacters(in: .whitespacesAndNewlines) let row = ScenicQueuePresetVoiceRow(text: phrase, playing: playing) row.tag = index row.onPlay = { [weak self] in guard let self else { return } if playing { self.viewModel.toggleCustomTTSPlayback() } else { self.viewModel.startPresetVoiceLoop(phrase) } } row.onDelete = { [weak self] in self?.viewModel.deletePresetVoice(at: index) } presetStack.addArrangedSubview(row) } } private func sectionCard(title: String, info: Bool = false, content: () -> [UIView]) -> UIView { let card = ScenicQueueSettingCard() let heading = sectionLabel(title) if info { heading.text = "\(title) ⓘ" } let stack = verticalStack([heading] + content(), spacing: 12) card.addSubview(stack) stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) } return card } private func numericField(key: String, unit: String, keyboard: UIKeyboardType = .numberPad) -> ScenicQueueNumericFieldView { let field = ScenicQueueNumericFieldView(key: key, unit: unit, keyboard: keyboard) field.delegate = self numericFields[key] = field return field } private func switchRow(key: String, title: String, action: Selector) -> ScenicQueueSwitchRow { let row = ScenicQueueSwitchRow(title: title) row.toggle.addTarget(self, action: action, for: .valueChanged) switches[key] = row return row } private func verticalStack(_ views: [UIView], spacing: CGFloat) -> UIStackView { let stack = UIStackView(arrangedSubviews: views) stack.axis = .vertical stack.spacing = spacing return stack } private func sectionLabel(_ text: String) -> UILabel { let label = UILabel() label.text = text label.font = ScenicQueueTokens.settingsSectionTitleFont label.textColor = AppColor.textPrimary label.numberOfLines = 0 return label } private func hintLabel(_ text: String) -> UILabel { let label = UILabel() label.text = text label.font = ScenicQueueTokens.settingsHintFont label.textColor = AppColor.textTertiary label.numberOfLines = 0 return label } private func divider() -> UIView { let view = UIView() view.backgroundColor = ScenicQueueTokens.cardOutline view.snp.makeConstraints { $0.height.equalTo(1 / UIScreen.main.scale) } return view } private func primaryButton(_ title: String, action: Selector) -> UIButton { let button = compactPrimaryButton(title, action: action) button.snp.makeConstraints { $0.height.equalTo(44) } return button } private func compactPrimaryButton(_ title: String, action: Selector) -> UIButton { let button = UIButton(type: .system) button.setTitle(title, for: .normal) button.setTitleColor(.white, for: .normal) button.titleLabel?.font = ScenicQueueTokens.settingsRowTitleFont button.backgroundColor = ScenicQueueTokens.bannerBlue button.layer.cornerRadius = ScenicQueueTokens.radiusInput button.addTarget(self, action: action, for: .touchUpInside) button.contentEdgeInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16) return button } private func outlinedButton(_ title: String, action: Selector) -> UIButton { let button = UIButton(type: .system) button.setTitle(title, for: .normal) button.setTitleColor(ScenicQueueTokens.bannerBlue, for: .normal) button.titleLabel?.font = ScenicQueueTokens.settingsRowTitleFont button.layer.cornerRadius = ScenicQueueTokens.radiusInput button.layer.borderWidth = 1 button.layer.borderColor = ScenicQueueTokens.bannerBlue.cgColor button.addTarget(self, action: action, for: .touchUpInside) button.snp.makeConstraints { $0.height.equalTo(44) } return button } private func timeButton(action: Selector) -> UIButton { let button = outlinedButton("--:--", action: action) button.setTitleColor(AppColor.textPrimary, for: .normal) button.layer.borderColor = ScenicQueueTokens.fieldBorderMuted.cgColor return button } private func labeledControl(_ title: String, _ control: UIView) -> UIView { let label = hintLabel(title) let stack = verticalStack([label, control], spacing: 8) return stack } private func loadPunchSpotMenuIfNeeded() { guard !punchSpotOptionsLoaded, !punchSpotOptionsLoading else { return } punchSpotOptionsLoading = true configurePunchSpotMenu() Task { await viewModel.loadPunchSpotOptions(api: api) punchSpotOptionsLoading = false punchSpotOptionsLoaded = true configurePunchSpotMenu() } } private func configurePunchSpotMenu() { if punchSpotOptionsLoading { punchSpotButton.menu = UIMenu(children: [ UIAction(title: "正在加载打卡点…", attributes: .disabled) { _ in } ]) return } let actions = viewModel.punchSpotOptions.map { option in UIAction( title: option.label, state: option.id == viewModel.selectedPunchSpotId ? .on : .off ) { [weak self] _ in guard let self else { return } Task { await self.viewModel.setPunchSpot(option, api: self.api) } } } punchSpotButton.menu = UIMenu( options: .singleSelection, children: actions.isEmpty ? [UIAction(title: "暂无可选打卡点", attributes: .disabled) { _ in }] : actions ) } private func presentTimePicker(initial: String, setter: @escaping (String) -> Void) { present( ScenicQueueTimePickerViewController(initialTime: initial, onConfirm: setter), animated: true) } @objc private func logTapped() { viewModel.openSettingChangeLog() } @objc private func saveTapped() { view.endEditing(true) Task { await viewModel.save(api: api) } } @objc private func saveQRTapped() { Task { await viewModel.saveShootQueueQRCode(api: api) } } @objc private func startTimeTapped() { presentTimePicker(initial: viewModel.businessStartTime, setter: viewModel.setBusinessStartTime) } @objc private func endTimeTapped() { presentTimePicker(initial: viewModel.businessEndTime, setter: viewModel.setBusinessEndTime) } @objc private func testSoundTapped() { viewModel.playBluetoothTestSound() } @objc private func customPlayTapped() { viewModel.toggleCustomTTSPlayback() } @objc private func savePresetTapped() { viewModel.setCustomTTSText(customTextView.text ?? "") viewModel.saveCurrentTextAsPresetVoiceLocal() } @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() } } extension ScenicQueueSettingsViewController: UIPageViewControllerDataSource, UIPageViewControllerDelegate { func pageViewController( _ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController ) -> UIViewController? { guard let page = viewController as? ScenicQueueSettingsPageController, page.settingsTab.rawValue > 0 else { return nil } return pages[page.settingsTab.rawValue - 1] } func pageViewController( _ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController ) -> UIViewController? { guard let page = viewController as? ScenicQueueSettingsPageController, page.settingsTab.rawValue < pages.count - 1 else { return nil } return pages[page.settingsTab.rawValue + 1] } func pageViewController( _ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool ) { guard completed, let page = pageViewController.viewControllers?.first as? ScenicQueueSettingsPageController else { return } viewModel.setSettingsTab(page.settingsTab) tabStrip.setSelectedIndex(page.settingsTab.rawValue, animated: true) } } extension ScenicQueueSettingsViewController: ScenicQueueNumericFieldViewDelegate { func numericFieldView(_ field: ScenicQueueNumericFieldView, commit raw: String, key: String) -> Bool { func integer(_ range: ClosedRange, setter: (Int) -> Void) -> Bool { guard let value = Int(raw), range.contains(value) else { return false } setter(value) return true } let valid: Bool switch key { case "queueDistanceMeter": if let meters = ScenicQueueSettingsViewModel.meters(fromKilometersText: raw) { viewModel.setQueueDistanceMeter(meters) valid = true } else { valid = false } case "shootMinute": valid = integer(0...30, setter: viewModel.setShootMinute) case "shootSecond": valid = integer(0...59, setter: viewModel.setShootSecond) case "queueTakeLimit": valid = integer(0...999_999, setter: viewModel.setQueueTakeLimit) case "missCallRequeueOffset": valid = integer(1...9_999, setter: viewModel.setMissCallRequeueOffset) case "autoCallAheadCount": valid = integer(0...5, setter: viewModel.setAutoCallAheadCount) case "firstNoticeAhead": valid = integer(0...50, setter: viewModel.setFirstAheadCount) case "secondNoticeAhead": valid = integer(0...50, setter: viewModel.setSecondAheadCount) case "broadcastIntervalSec": valid = integer(40...60, setter: viewModel.setBroadcastIntervalSec) case "countdownReadableThresholdSec": valid = integer(10...30, setter: viewModel.setCountdownThresholdSec) default: valid = false } viewModel.reportOutOfRangeNumericPending(fieldKey: key, pending: !valid) if !valid { showToast("输入值超出允许范围") } return valid } } extension ScenicQueueSettingsViewController: UITextViewDelegate { func textViewDidChange(_ textView: UITextView) { if textView.text.count > 255 { textView.text = String(textView.text.prefix(255)) } viewModel.setCustomTTSText(textView.text) } } /// 设置页单个分页的持久化滚动容器。 final class ScenicQueueSettingsPageController: UIViewController { let settingsTab: ScenicQueueSettingsTab let stack = UIStackView() private let scrollView = UIScrollView() init(tab: ScenicQueueSettingsTab) { self.settingsTab = tab super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = ScenicQueueTokens.pageBackground scrollView.keyboardDismissMode = .onDrag scrollView.alwaysBounceVertical = true stack.axis = .vertical stack.spacing = 14 view.addSubview(scrollView) scrollView.addSubview(stack) scrollView.snp.makeConstraints { $0.edges.equalToSuperview() } stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 16, right: 16)) make.width.equalToSuperview().offset(-32) } } } /// 设置页白色描边卡片。 final class ScenicQueueSettingCard: UIView { init() { super.init(frame: .zero) backgroundColor = .white layer.cornerRadius = ScenicQueueTokens.radiusCard layer.borderWidth = 1 layer.borderColor = ScenicQueueTokens.cardOutline.cgColor } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } /// 数字输入视图提交协议。 protocol ScenicQueueNumericFieldViewDelegate: AnyObject { func numericFieldView(_ field: ScenicQueueNumericFieldView, commit raw: String, key: String) -> Bool } /// Android OutlinedTextField 样式的数字与单位输入视图。 final class ScenicQueueNumericFieldView: UIView, UITextFieldDelegate { let key: String weak var delegate: ScenicQueueNumericFieldViewDelegate? private let textField = UITextField() private var invalid = false init(key: String, unit: String, keyboard: UIKeyboardType) { self.key = key super.init(frame: .zero) let unitLabel = UILabel() unitLabel.text = unit unitLabel.font = ScenicQueueTokens.settingsHintFont unitLabel.textColor = AppColor.textSecondary unitLabel.textAlignment = .right unitLabel.setContentHuggingPriority(.required, for: .horizontal) unitLabel.setContentCompressionResistancePriority(.required, for: .horizontal) textField.keyboardType = keyboard textField.font = ScenicQueueTokens.settingsRowTitleFont textField.textColor = AppColor.textPrimary textField.delegate = self layer.cornerRadius = ScenicQueueTokens.radiusInput layer.borderWidth = 1 layer.borderColor = ScenicQueueTokens.fieldBorderMuted.cgColor addSubview(textField) addSubview(unitLabel) let focusTap = UITapGestureRecognizer(target: self, action: #selector(focusInput)) focusTap.cancelsTouchesInView = false addGestureRecognizer(focusTap) unitLabel.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(8) make.centerY.equalToSuperview() } textField.snp.makeConstraints { make in make.leading.equalToSuperview().offset(12) make.trailing.equalTo(unitLabel.snp.leading).offset(-8) make.top.bottom.equalToSuperview() } snp.makeConstraints { $0.height.equalTo(52) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func setDisplayValue(_ value: String) { if !textField.isFirstResponder && !invalid { textField.text = value } } func setInvalid(_ value: Bool) { invalid = value layer.borderColor = (value ? AppColor.danger : ScenicQueueTokens.fieldBorderMuted).cgColor } @objc private func focusInput() { textField.becomeFirstResponder() } func textFieldDidBeginEditing(_ textField: UITextField) { layer.borderColor = ScenicQueueTokens.bannerBlue.cgColor } func textFieldDidEndEditing(_ textField: UITextField) { let ok = delegate?.numericFieldView(self, commit: textField.text ?? "", key: key) ?? false setInvalid(!ok) } } /// 设置页标题与开关行。 final class ScenicQueueSwitchRow: UIView { let toggle = UISwitch() init(title: String) { super.init(frame: .zero) let label = UILabel() label.text = title label.font = ScenicQueueTokens.settingsRowTitleFont label.textColor = AppColor.textPrimary label.numberOfLines = 0 toggle.onTintColor = ScenicQueueTokens.bannerBlue addSubview(label) addSubview(toggle) label.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview() make.trailing.lessThanOrEqualTo(toggle.snp.leading).offset(-12) } toggle.snp.makeConstraints { make in make.trailing.centerY.equalToSuperview() } snp.makeConstraints { $0.height.greaterThanOrEqualTo(44) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func setValue(_ value: Bool) { toggle.setOn(value, animated: false) } } /// 已保存语音的播放与左滑删除行。 final class ScenicQueuePresetVoiceRow: UIView { var onPlay: (() -> Void)? var onDelete: (() -> Void)? private let content = UIView() private let deleteButton = UIButton(type: .system) private var revealed = false init(text: String, playing: Bool) { super.init(frame: .zero) layer.cornerRadius = ScenicQueueTokens.radiusInput clipsToBounds = true deleteButton.setTitle("删除", for: .normal) deleteButton.setTitleColor(.white, for: .normal) deleteButton.backgroundColor = UIColor(hex: 0xE53935) deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) content.backgroundColor = .white content.layer.borderWidth = 1 content.layer.borderColor = ScenicQueueTokens.cardOutline.cgColor content.layer.cornerRadius = ScenicQueueTokens.radiusInput let play = UIButton(type: .system) play.setImage(UIImage(systemName: playing ? "pause.fill" : "play.fill"), for: .normal) play.tintColor = ScenicQueueTokens.bannerBlue play.addTarget(self, action: #selector(playTapped), for: .touchUpInside) let label = UILabel() label.text = text label.font = ScenicQueueTokens.settingsRowTitleFont label.textColor = AppColor.textPrimary label.numberOfLines = 3 addSubview(deleteButton) addSubview(content) content.addSubview(play) content.addSubview(label) deleteButton.snp.makeConstraints { make in make.trailing.top.bottom.equalToSuperview() make.width.equalTo(80) } content.snp.makeConstraints { $0.edges.equalToSuperview() } play.snp.makeConstraints { make in make.leading.equalToSuperview().offset(4) make.centerY.equalToSuperview() make.size.equalTo(44) } label.snp.makeConstraints { make in make.leading.equalTo(play.snp.trailing).offset(4) make.trailing.equalToSuperview().offset(-12) make.top.bottom.equalToSuperview().inset(12) } addGestureRecognizer(UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:)))) (gestureRecognizers?.last as? UISwipeGestureRecognizer)?.direction = .left let right = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:))) right.direction = .right addGestureRecognizer(right) snp.makeConstraints { $0.height.greaterThanOrEqualTo(52) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @objc private func playTapped() { onPlay?() } @objc private func deleteTapped() { onDelete?() } @objc private func swiped(_ gesture: UISwipeGestureRecognizer) { revealed = gesture.direction == .left content.snp.updateConstraints { make in make.leading.equalToSuperview().offset(revealed ? -80 : 0) make.trailing.equalToSuperview().offset(revealed ? -80 : 0) } UIView.animate(withDuration: 0.2) { self.layoutIfNeeded() } } } /// 营业时间的 Android 风格居中时间选择弹层。 final class ScenicQueueTimePickerViewController: UIViewController { private let onConfirm: (String) -> Void private let picker = UIDatePicker() init(initialTime: String, onConfirm: @escaping (String) -> Void) { self.onConfirm = onConfirm super.init(nibName: nil, bundle: nil) modalPresentationStyle = .overFullScreen modalTransitionStyle = .crossDissolve let formatter = DateFormatter() formatter.dateFormat = "HH:mm" picker.date = formatter.date(from: initialTime) ?? Date() } @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.radiusDialog picker.datePickerMode = .time picker.preferredDatePickerStyle = .wheels picker.locale = Locale(identifier: "zh_CN") let cancel = button("取消", filled: false, action: #selector(cancelTapped)) let confirm = button("确认", filled: true, action: #selector(confirmTapped)) let row = UIStackView(arrangedSubviews: [cancel, confirm]) row.axis = .horizontal row.spacing = 12 row.distribution = .fillEqually view.addSubview(card) card.addSubview(picker) card.addSubview(row) card.snp.makeConstraints { make in make.center.equalToSuperview() make.leading.trailing.equalToSuperview().inset(32) } picker.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.equalTo(190) } row.snp.makeConstraints { make in make.top.equalTo(picker.snp.bottom) make.leading.trailing.bottom.equalToSuperview().inset(20) make.height.equalTo(44) } } private func button(_ title: String, filled: Bool, action: Selector) -> UIButton { let b = UIButton(type: .system) b.setTitle(title, for: .normal) b.setTitleColor(filled ? .white : ScenicQueueTokens.bannerBlue, for: .normal) b.backgroundColor = filled ? ScenicQueueTokens.bannerBlue : ScenicQueueTokens.dialogCancelBackground b.layer.cornerRadius = 8 b.addTarget(self, action: action, for: .touchUpInside) return b } @objc private func cancelTapped() { dismiss(animated: true) } @objc private func confirmTapped() { let formatter = DateFormatter() formatter.dateFormat = "HH:mm" let value = formatter.string(from: picker.date) dismiss(animated: true) { self.onConfirm(value) } } }