// // 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(_:))) addSwitchRow(key: "offlineTTS", title: "离线语音合成", isOn: viewModel.useOfflineTTS, action: #selector(offlineTTSChanged(_:))) 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 offlineTTSChanged(_ sender: UISwitch) { viewModel.setUseOfflineTTS(sender.isOn) } @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") } }