新增景区排队管理功能
This commit is contained in:
@ -0,0 +1,152 @@
|
||||
//
|
||||
// ScenicQueueSettingChangeLogViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 排队设置配置日志页。
|
||||
final class ScenicQueueSettingChangeLogViewController: BaseViewController {
|
||||
private let viewModel = ScenicQueueSettingChangeLogViewModel()
|
||||
private let api: ScenicQueueAPIProtocol
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let emptyLabel = UILabel()
|
||||
|
||||
@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 viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
Task { await viewModel.refresh(api: api) }
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "配置日志"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = ScenicQueueTokens.pageBackground
|
||||
tableView.backgroundColor = .clear
|
||||
tableView.separatorStyle = .none
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 120
|
||||
tableView.delegate = self
|
||||
tableView.dataSource = self
|
||||
tableView.register(ScenicQueueLogCell.self, forCellReuseIdentifier: ScenicQueueLogCell.reuseIdentifier)
|
||||
tableView.refreshControl = UIRefreshControl()
|
||||
tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||
emptyLabel.text = "暂无配置变更记录"
|
||||
emptyLabel.textColor = AppColor.textTertiary
|
||||
emptyLabel.font = ScenicQueueTokens.emptyStateFont
|
||||
emptyLabel.textAlignment = .center
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(emptyLabel)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||||
}
|
||||
emptyLabel.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyState() }
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
}
|
||||
|
||||
private func applyState() {
|
||||
tableView.refreshControl?.endRefreshing()
|
||||
tableView.reloadData()
|
||||
emptyLabel.isHidden = !viewModel.items.isEmpty
|
||||
}
|
||||
|
||||
@objc private func refreshPulled() {
|
||||
Task { await viewModel.refresh(api: api) }
|
||||
}
|
||||
}
|
||||
|
||||
extension ScenicQueueSettingChangeLogViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
viewModel.items.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueLogCell.reuseIdentifier, for: indexPath) as! ScenicQueueLogCell
|
||||
cell.configure(item: viewModel.items[indexPath.row])
|
||||
return cell
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 120 else { return }
|
||||
Task { await viewModel.loadMore(api: api) }
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置日志 cell。
|
||||
final class ScenicQueueLogCell: UITableViewCell {
|
||||
static let reuseIdentifier = "ScenicQueueLogCell"
|
||||
|
||||
private let card = UIView()
|
||||
private let titleLabel = UILabel()
|
||||
private let detailLabel = UILabel()
|
||||
private let timeLabel = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(item: ScenicQueueSettingChangeLogItem) {
|
||||
titleLabel.text = item.operatorName.isEmpty ? "配置变更" : item.operatorName
|
||||
detailLabel.text = item.displayText.isEmpty ? item.summary : item.displayText
|
||||
timeLabel.text = item.createdAt
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = ScenicQueueTokens.radiusCard
|
||||
card.layer.borderWidth = 1
|
||||
card.layer.borderColor = ScenicQueueTokens.cardOutline.cgColor
|
||||
contentView.addSubview(card)
|
||||
titleLabel.font = ScenicQueueTokens.settingsSectionTitleFont
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
detailLabel.font = ScenicQueueTokens.settingsRowTitleFont
|
||||
detailLabel.textColor = AppColor.textSecondary
|
||||
detailLabel.numberOfLines = 0
|
||||
timeLabel.font = ScenicQueueTokens.settingsHintFont
|
||||
timeLabel.textColor = AppColor.textTertiary
|
||||
let stack = UIStackView(arrangedSubviews: [titleLabel, detailLabel, timeLabel])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
card.addSubview(stack)
|
||||
card.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 16, bottom: 6, right: 16))
|
||||
}
|
||||
stack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(14)
|
||||
}
|
||||
}
|
||||
}
|
||||
427
suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift
Normal file
427
suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
414
suixinkan/UI/ScenicQueue/ScenicQueueViewController.swift
Normal file
414
suixinkan/UI/ScenicQueue/ScenicQueueViewController.swift
Normal file
@ -0,0 +1,414 @@
|
||||
//
|
||||
// ScenicQueueViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 排队管理首页,严格对齐 Android `ScenicQueueScreen` 的布局与交互。
|
||||
final class ScenicQueueViewController: BaseViewController {
|
||||
private let viewModel = ScenicQueueViewModel()
|
||||
private let api: ScenicQueueAPIProtocol
|
||||
|
||||
private let punchSpotRow = UIView()
|
||||
private let punchSpotLabel = UILabel()
|
||||
private let statsBanner = ScenicQueueStatsBannerView()
|
||||
private let contentContainer = UIView()
|
||||
private let segmentedControl = UISegmentedControl(items: ["当前排队", "过号列表"])
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let bottomBar = UIView()
|
||||
private let prepareCallButton = UIButton(type: .system)
|
||||
private let quickCallButton = UIButton(type: .system)
|
||||
private let emptyLabel = UILabel()
|
||||
|
||||
private var selectedTab = 0
|
||||
|
||||
@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 viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
Task { await viewModel.refreshQueueGate(api: api) }
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
viewModel.startQueueStatsPolling(api: api)
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
viewModel.stopQueueStatsPolling()
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "排队管理"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
image: UIImage(systemName: "gearshape"),
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(settingsTapped)
|
||||
)
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = ScenicQueueTokens.pageBackground
|
||||
|
||||
punchSpotRow.backgroundColor = .white
|
||||
let locationIcon = UIImageView(image: UIImage(systemName: "mappin.and.ellipse"))
|
||||
locationIcon.tintColor = ScenicQueueTokens.bannerBlue
|
||||
punchSpotLabel.font = .systemFont(ofSize: 16)
|
||||
punchSpotLabel.textColor = UIColor.black.withAlphaComponent(0.7)
|
||||
punchSpotLabel.lineBreakMode = .byTruncatingTail
|
||||
punchSpotRow.addSubview(locationIcon)
|
||||
punchSpotRow.addSubview(punchSpotLabel)
|
||||
|
||||
contentContainer.backgroundColor = .white
|
||||
contentContainer.layer.cornerRadius = ScenicQueueTokens.radiusContentSheetTop
|
||||
contentContainer.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||
contentContainer.clipsToBounds = true
|
||||
|
||||
segmentedControl.selectedSegmentIndex = 0
|
||||
segmentedControl.selectedSegmentTintColor = .white
|
||||
segmentedControl.setTitleTextAttributes([
|
||||
.font: ScenicQueueTokens.tabFont,
|
||||
.foregroundColor: AppColor.textSecondary,
|
||||
], for: .normal)
|
||||
segmentedControl.setTitleTextAttributes([
|
||||
.font: ScenicQueueTokens.tabSelectedFont,
|
||||
.foregroundColor: ScenicQueueTokens.bannerBlue,
|
||||
], for: .selected)
|
||||
|
||||
tableView.backgroundColor = .white
|
||||
tableView.separatorStyle = .none
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 260
|
||||
tableView.delegate = self
|
||||
tableView.dataSource = self
|
||||
tableView.register(ScenicQueueTicketCell.self, forCellReuseIdentifier: ScenicQueueTicketCell.reuseIdentifier)
|
||||
tableView.register(ScenicQueueSkippedCell.self, forCellReuseIdentifier: ScenicQueueSkippedCell.reuseIdentifier)
|
||||
tableView.refreshControl = UIRefreshControl()
|
||||
tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||
|
||||
emptyLabel.textColor = AppColor.textTertiary
|
||||
emptyLabel.font = ScenicQueueTokens.emptyStateFont
|
||||
emptyLabel.textAlignment = .center
|
||||
|
||||
bottomBar.backgroundColor = .white
|
||||
bottomBar.layer.shadowColor = UIColor.black.cgColor
|
||||
bottomBar.layer.shadowOpacity = 0.12
|
||||
bottomBar.layer.shadowRadius = 8
|
||||
bottomBar.layer.shadowOffset = CGSize(width: 0, height: -2)
|
||||
configureBottomButton(prepareCallButton, title: "准备叫号", subtitle: "服务人员操作", image: UIImage(systemName: "play.fill"), color: ScenicQueueTokens.bannerBlue)
|
||||
configureBottomButton(quickCallButton, title: "快速叫号", subtitle: "叫号人员操作", image: UIImage(systemName: "person.fill"), color: UIColor(hex: 0x7C3AED))
|
||||
|
||||
view.addSubview(punchSpotRow)
|
||||
view.addSubview(statsBanner)
|
||||
view.addSubview(contentContainer)
|
||||
contentContainer.addSubview(segmentedControl)
|
||||
contentContainer.addSubview(tableView)
|
||||
contentContainer.addSubview(emptyLabel)
|
||||
view.addSubview(bottomBar)
|
||||
bottomBar.addSubview(prepareCallButton)
|
||||
bottomBar.addSubview(quickCallButton)
|
||||
|
||||
locationIcon.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(24)
|
||||
}
|
||||
punchSpotLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(locationIcon.snp.trailing).offset(6)
|
||||
make.trailing.equalToSuperview().offset(-16)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
punchSpotRow.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(56)
|
||||
}
|
||||
statsBanner.snp.makeConstraints { make in
|
||||
make.top.equalTo(punchSpotRow.snp.bottom)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(96)
|
||||
}
|
||||
contentContainer.snp.makeConstraints { make in
|
||||
make.top.equalTo(statsBanner.snp.bottom).offset(16)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.bottom.equalTo(bottomBar.snp.top).offset(-8)
|
||||
}
|
||||
segmentedControl.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview().inset(12)
|
||||
make.height.equalTo(40)
|
||||
}
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.equalTo(segmentedControl.snp.bottom).offset(8)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
emptyLabel.snp.makeConstraints { make in
|
||||
make.center.equalTo(tableView)
|
||||
}
|
||||
bottomBar.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
prepareCallButton.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(10)
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-10)
|
||||
make.height.equalTo(68)
|
||||
}
|
||||
quickCallButton.snp.makeConstraints { make in
|
||||
make.top.bottom.width.equalTo(prepareCallButton)
|
||||
make.leading.equalTo(prepareCallButton.snp.trailing).offset(10)
|
||||
make.trailing.equalToSuperview().offset(-16)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
segmentedControl.addTarget(self, action: #selector(tabChanged), for: .valueChanged)
|
||||
prepareCallButton.addTarget(self, action: #selector(prepareCallTapped), for: .touchUpInside)
|
||||
quickCallButton.addTarget(self, action: #selector(quickCallTapped), 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.onRequireSettings = { [weak self] in
|
||||
Task { @MainActor in self?.presentParametersRequiredDialog() }
|
||||
}
|
||||
viewModel.onNavigateSettings = { [weak self] in
|
||||
Task { @MainActor in self?.navigationController?.pushViewController(ScenicQueueSettingsViewController(), animated: true) }
|
||||
}
|
||||
viewModel.onNavigateBack = { [weak self] in
|
||||
Task { @MainActor in self?.navigationController?.popViewController(animated: true) }
|
||||
}
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(handleQueueDidUpdate), name: NotificationName.scenicQueueDidUpdate, object: nil)
|
||||
}
|
||||
|
||||
private func applyState() {
|
||||
punchSpotLabel.text = viewModel.selectedPunchSpotLine
|
||||
statsBanner.configure(summary: viewModel.summary)
|
||||
tableView.refreshControl?.endRefreshing()
|
||||
tableView.reloadData()
|
||||
let isEmpty = selectedTab == 0 ? viewModel.currentQueue.isEmpty : viewModel.skippedQueue.isEmpty
|
||||
emptyLabel.text = selectedTab == 0 ? "暂无排队" : "暂无过号记录"
|
||||
emptyLabel.isHidden = !isEmpty
|
||||
prepareCallButton.isHidden = !viewModel.prepareCallButtonEnabled
|
||||
quickCallButton.isHidden = !viewModel.quickCallButtonEnabled
|
||||
prepareCallButton.setImage(UIImage(systemName: viewModel.prepareCallPlaying ? "pause.fill" : "play.fill"), for: .normal)
|
||||
bottomBar.isHidden = !viewModel.queueGatePassed || (!viewModel.prepareCallButtonEnabled && !viewModel.quickCallButtonEnabled)
|
||||
}
|
||||
|
||||
private func configureBottomButton(_ button: UIButton, title: String, subtitle: String, image: UIImage?, color: UIColor) {
|
||||
var config = UIButton.Configuration.filled()
|
||||
config.baseBackgroundColor = color
|
||||
config.baseForegroundColor = .white
|
||||
config.image = image
|
||||
config.imagePadding = 10
|
||||
config.cornerStyle = .medium
|
||||
var titleAttributes = AttributeContainer()
|
||||
titleAttributes.font = UIFont.systemFont(ofSize: 15, weight: .semibold)
|
||||
var subtitleAttributes = AttributeContainer()
|
||||
subtitleAttributes.font = UIFont.systemFont(ofSize: 11)
|
||||
subtitleAttributes.foregroundColor = UIColor.white.withAlphaComponent(0.82)
|
||||
config.attributedTitle = AttributedString(title, attributes: titleAttributes)
|
||||
config.attributedSubtitle = AttributedString(subtitle, attributes: subtitleAttributes)
|
||||
button.configuration = config
|
||||
button.tintColor = .white
|
||||
}
|
||||
|
||||
private func presentParametersRequiredDialog() {
|
||||
guard presentedViewController == nil else { return }
|
||||
let alert = UIAlertController(title: "排队参数未配置", message: "请先选择打卡点并保存排队设置", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "暂不进入", style: .cancel) { [weak self] _ in
|
||||
self?.viewModel.exitQueueModule()
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "去设置", style: .default) { [weak self] _ in
|
||||
self?.navigationController?.pushViewController(ScenicQueueSettingsViewController(), animated: true)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentConfirm(title: String, message: String?, confirm: @escaping () -> Void) {
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "确认", style: .default) { _ in confirm() })
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func openDial(_ digits: String) {
|
||||
guard !digits.isEmpty else { return }
|
||||
let alert = UIAlertController(title: "拨打电话", message: digits, preferredStyle: .actionSheet)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "拨打", style: .default) { _ in
|
||||
if let url = URL(string: "tel:\(digits)") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
@objc private func settingsTapped() {
|
||||
viewModel.openSettings()
|
||||
}
|
||||
|
||||
@objc private func tabChanged() {
|
||||
selectedTab = segmentedControl.selectedSegmentIndex
|
||||
tableView.reloadData()
|
||||
applyState()
|
||||
}
|
||||
|
||||
@objc private func refreshPulled() {
|
||||
Task {
|
||||
if selectedTab == 0 {
|
||||
await viewModel.refreshCurrentQueue(api: api)
|
||||
} else {
|
||||
await viewModel.refreshSkippedQueue(api: api)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func prepareCallTapped() {
|
||||
viewModel.togglePrepareCallPlayback()
|
||||
}
|
||||
|
||||
@objc private func quickCallTapped() {
|
||||
Task { await viewModel.quickCallFirstTicket(api: api) }
|
||||
}
|
||||
|
||||
@objc private func handleQueueDidUpdate() {
|
||||
Task {
|
||||
await viewModel.refreshQueueStats(api: api)
|
||||
await viewModel.refreshCurrentQueue(api: api)
|
||||
await viewModel.refreshSkippedQueue(api: api)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ScenicQueueViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
selectedTab == 0 ? viewModel.currentQueue.count : viewModel.skippedQueue.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
if selectedTab == 0 {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueTicketCell.reuseIdentifier, for: indexPath) as! ScenicQueueTicketCell
|
||||
let ticket = viewModel.currentQueue[indexPath.row]
|
||||
cell.delegate = self
|
||||
cell.configure(
|
||||
ticket: ticket,
|
||||
shootingQueueNo: viewModel.shootingTimedQueueNo,
|
||||
remainSeconds: viewModel.shootingRemainSeconds,
|
||||
showStartShootingButton: viewModel.showStartShootingButton
|
||||
)
|
||||
return cell
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueSkippedCell.reuseIdentifier, for: indexPath) as! ScenicQueueSkippedCell
|
||||
cell.delegate = self
|
||||
cell.configure(ticket: viewModel.skippedQueue[indexPath.row])
|
||||
return cell
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 120 else { return }
|
||||
Task {
|
||||
if selectedTab == 0 {
|
||||
await viewModel.loadMoreCurrentQueue(api: api)
|
||||
} else {
|
||||
await viewModel.loadMoreSkippedQueue(api: api)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
|
||||
func scenicQueueTicketCellDidTapDial(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
|
||||
openDial(ticket.dialPhoneDigits)
|
||||
}
|
||||
|
||||
func scenicQueueTicketCellDidTapSkip(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
|
||||
viewModel.requestSkipTicket(recordId: ticket.recordId, queueNo: ticket.queueNo)
|
||||
presentConfirm(title: "确认过号", message: ticket.queueNo) { [weak self] in
|
||||
guard let self else { return }
|
||||
Task { await self.viewModel.confirmSkipTicket(api: self.api) }
|
||||
}
|
||||
}
|
||||
|
||||
func scenicQueueTicketCellDidTapPrimary(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
|
||||
if ticket.status == .waiting {
|
||||
viewModel.requestCallConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo)
|
||||
presentConfirm(title: "确认叫号", message: ticket.queueNo) { [weak self] in
|
||||
guard let self else { return }
|
||||
Task { await self.viewModel.confirmCallTicket(api: self.api) }
|
||||
}
|
||||
} else {
|
||||
viewModel.startShooting(api: api, recordId: ticket.recordId, queueNo: ticket.queueNo)
|
||||
}
|
||||
}
|
||||
|
||||
func scenicQueueTicketCellDidTapRecall(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
|
||||
viewModel.requestCallConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo, isRecall: true)
|
||||
presentConfirm(title: "重新叫号", message: ticket.queueNo) { [weak self] in
|
||||
guard let self else { return }
|
||||
Task { await self.viewModel.confirmCallTicket(api: self.api) }
|
||||
}
|
||||
}
|
||||
|
||||
func scenicQueueTicketCellDidTapComplete(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
|
||||
viewModel.requestFinishConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo)
|
||||
presentConfirm(title: "确认完成拍摄", message: ticket.queueNo) { [weak self] in
|
||||
guard let self else { return }
|
||||
Task { await self.viewModel.confirmFinishTicket(api: self.api) }
|
||||
}
|
||||
}
|
||||
|
||||
func scenicQueueTicketCellDidTapMark(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
|
||||
viewModel.onTicketMarkClick(recordId: ticket.recordId, queueNo: ticket.queueNo, uid: ticket.uid)
|
||||
let alert = UIAlertController(title: "标记用户", message: "\(ticket.queueNo)\n\(ticket.phoneMasked)", preferredStyle: .alert)
|
||||
alert.addTextField { field in
|
||||
field.placeholder = "限制排队天数"
|
||||
field.keyboardType = .numberPad
|
||||
field.text = "0"
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in
|
||||
self?.viewModel.dismissMarkDialog()
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "标记打野", style: .default) { [weak self, weak alert] _ in
|
||||
guard let self else { return }
|
||||
let days = Int(alert?.textFields?.first?.text ?? "") ?? 0
|
||||
Task { await self.viewModel.confirmUserMark(api: self.api, markAsFreelancePhotog: true, queueBanDays: days) }
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "取消标记", style: .destructive) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { await self.viewModel.confirmUserMark(api: self.api, markAsFreelancePhotog: false, queueBanDays: 0) }
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension ScenicQueueViewController: ScenicQueueSkippedCellDelegate {
|
||||
func scenicQueueSkippedCellDidTapDial(_ cell: ScenicQueueSkippedCell, ticket: ScenicQueueSkippedTicket) {
|
||||
openDial(ticket.dialPhoneDigits)
|
||||
}
|
||||
|
||||
func scenicQueueSkippedCellDidTapRequeue(_ cell: ScenicQueueSkippedCell, ticket: ScenicQueueSkippedTicket) {
|
||||
viewModel.requestRequeueConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo)
|
||||
presentConfirm(title: "确认重新排队", message: ticket.queueNo) { [weak self] in
|
||||
guard let self else { return }
|
||||
Task { await self.viewModel.confirmRequeueTicket(api: self.api) }
|
||||
}
|
||||
}
|
||||
}
|
||||
543
suixinkan/UI/ScenicQueue/Views/ScenicQueueViews.swift
Normal file
543
suixinkan/UI/ScenicQueue/Views/ScenicQueueViews.swift
Normal file
@ -0,0 +1,543 @@
|
||||
//
|
||||
// ScenicQueueViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 排队统计 Banner。
|
||||
final class ScenicQueueStatsBannerView: UIView {
|
||||
private let peopleValueLabel = UILabel()
|
||||
private let avgValueLabel = UILabel()
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 更新统计。
|
||||
func configure(summary: ScenicQueueSummary) {
|
||||
peopleValueLabel.text = "\(summary.peopleInQueue)"
|
||||
avgValueLabel.text = "\(summary.avgWaitMinutes)"
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
backgroundColor = ScenicQueueTokens.bannerBlue
|
||||
|
||||
let left = metricColumn(title: "在排人数", valueLabel: peopleValueLabel, unit: "人")
|
||||
let right = metricColumn(title: "平均等待", valueLabel: avgValueLabel, unit: "分钟")
|
||||
let divider = UIView()
|
||||
divider.backgroundColor = ScenicQueueTokens.bannerDivider
|
||||
|
||||
addSubview(left)
|
||||
addSubview(divider)
|
||||
addSubview(right)
|
||||
|
||||
left.snp.makeConstraints { make in
|
||||
make.leading.top.bottom.equalToSuperview()
|
||||
make.trailing.equalTo(divider.snp.leading)
|
||||
make.width.equalTo(right)
|
||||
}
|
||||
divider.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview()
|
||||
make.centerY.equalToSuperview()
|
||||
make.width.equalTo(0.8)
|
||||
make.height.equalTo(42)
|
||||
}
|
||||
right.snp.makeConstraints { make in
|
||||
make.leading.equalTo(divider.snp.trailing)
|
||||
make.trailing.top.bottom.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
private func metricColumn(title: String, valueLabel: UILabel, unit: String) -> UIView {
|
||||
let container = UIView()
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = title
|
||||
titleLabel.textColor = ScenicQueueTokens.bannerMutedLabel
|
||||
titleLabel.font = ScenicQueueTokens.bannerLabelFont
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
valueLabel.textColor = .white
|
||||
valueLabel.font = ScenicQueueTokens.bannerValueFont
|
||||
valueLabel.textAlignment = .center
|
||||
|
||||
let unitLabel = UILabel()
|
||||
unitLabel.text = unit
|
||||
unitLabel.textColor = .white
|
||||
unitLabel.font = ScenicQueueTokens.bannerUnitFont
|
||||
|
||||
let valueStack = UIStackView(arrangedSubviews: [valueLabel, unitLabel])
|
||||
valueStack.axis = .horizontal
|
||||
valueStack.alignment = .lastBaseline
|
||||
valueStack.spacing = 2
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [titleLabel, valueStack])
|
||||
stack.axis = .vertical
|
||||
stack.alignment = .center
|
||||
stack.spacing = 6
|
||||
container.addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
return container
|
||||
}
|
||||
}
|
||||
|
||||
/// 排队列表标签。
|
||||
final class ScenicQueueTagLabel: UILabel {
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
font = ScenicQueueTokens.statusChipFont
|
||||
textAlignment = .center
|
||||
layer.cornerRadius = ScenicQueueTokens.radiusChip
|
||||
clipsToBounds = true
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 设置标签文案与颜色。
|
||||
func configure(text: String, foreground: UIColor, background: UIColor) {
|
||||
self.text = text
|
||||
textColor = foreground
|
||||
backgroundColor = background
|
||||
}
|
||||
}
|
||||
|
||||
/// 排队操作按钮。
|
||||
final class ScenicQueueActionButton: UIButton {
|
||||
init(title: String, background: UIColor, foreground: UIColor) {
|
||||
super.init(frame: .zero)
|
||||
setTitle(title, for: .normal)
|
||||
titleLabel?.font = ScenicQueueTokens.buttonPrimaryFont
|
||||
setTitleColor(foreground, for: .normal)
|
||||
backgroundColor = background
|
||||
layer.cornerRadius = ScenicQueueTokens.radiusPrimaryRowButton
|
||||
clipsToBounds = true
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前排队 cell 交互代理。
|
||||
protocol ScenicQueueTicketCellDelegate: AnyObject {
|
||||
func scenicQueueTicketCellDidTapDial(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket)
|
||||
func scenicQueueTicketCellDidTapSkip(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket)
|
||||
func scenicQueueTicketCellDidTapPrimary(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket)
|
||||
func scenicQueueTicketCellDidTapRecall(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket)
|
||||
func scenicQueueTicketCellDidTapComplete(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket)
|
||||
func scenicQueueTicketCellDidTapMark(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket)
|
||||
}
|
||||
|
||||
/// 当前排队列表 cell。
|
||||
final class ScenicQueueTicketCell: UITableViewCell {
|
||||
static let reuseIdentifier = "ScenicQueueTicketCell"
|
||||
|
||||
weak var delegate: ScenicQueueTicketCellDelegate?
|
||||
|
||||
private let cardView = UIView()
|
||||
private let queueNoLabel = UILabel()
|
||||
private let markButton = UIButton(type: .system)
|
||||
private let tagStack = UIStackView()
|
||||
private let metaStack = UIStackView()
|
||||
private let actionsStack = UIStackView()
|
||||
private let recallButton = ScenicQueueActionButton(title: "重新叫号", background: ScenicQueueTokens.buttonCompleteBackground, foreground: ScenicQueueTokens.bannerBlue)
|
||||
private let completeButton = ScenicQueueActionButton(title: "完成", background: ScenicQueueTokens.buttonCompleteBackground, foreground: ScenicQueueTokens.bannerBlue)
|
||||
private var ticket: ScenicQueueTicket?
|
||||
private var showStartShootingButton = true
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 配置当前排队 cell。
|
||||
func configure(ticket: ScenicQueueTicket, shootingQueueNo: String?, remainSeconds: Int, showStartShootingButton: Bool) {
|
||||
self.ticket = ticket
|
||||
self.showStartShootingButton = showStartShootingButton
|
||||
queueNoLabel.text = ticket.queueNo
|
||||
markButton.isHidden = ticket.markAsPhotog == 1
|
||||
rebuildTags(ticket)
|
||||
rebuildMeta(ticket)
|
||||
rebuildActions(ticket: ticket, shootingQueueNo: shootingQueueNo, remainSeconds: remainSeconds)
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
contentView.backgroundColor = .clear
|
||||
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = ScenicQueueTokens.radiusCard
|
||||
cardView.layer.borderWidth = 1
|
||||
cardView.layer.borderColor = ScenicQueueTokens.cardOutline.cgColor
|
||||
cardView.clipsToBounds = true
|
||||
contentView.addSubview(cardView)
|
||||
|
||||
queueNoLabel.font = ScenicQueueTokens.ticketIdFont
|
||||
queueNoLabel.textColor = AppColor.textPrimary
|
||||
|
||||
markButton.setTitle("标记", for: .normal)
|
||||
markButton.titleLabel?.font = ScenicQueueTokens.rowLabelFont
|
||||
markButton.setTitleColor(ScenicQueueTokens.bannerBlue, for: .normal)
|
||||
markButton.addTarget(self, action: #selector(markTapped), for: .touchUpInside)
|
||||
|
||||
let titleRow = UIStackView(arrangedSubviews: [queueNoLabel, markButton, UIView(), tagStack])
|
||||
titleRow.axis = .horizontal
|
||||
titleRow.alignment = .center
|
||||
titleRow.spacing = 4
|
||||
tagStack.axis = .horizontal
|
||||
tagStack.spacing = 6
|
||||
tagStack.alignment = .center
|
||||
|
||||
metaStack.axis = .vertical
|
||||
metaStack.spacing = 10
|
||||
|
||||
actionsStack.axis = .vertical
|
||||
actionsStack.spacing = 10
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [titleRow, metaStack, actionsStack])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 12
|
||||
cardView.addSubview(stack)
|
||||
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 5, left: 12, bottom: 5, right: 12))
|
||||
}
|
||||
stack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 14, left: 16, bottom: 14, right: 16))
|
||||
}
|
||||
}
|
||||
|
||||
private func rebuildTags(_ ticket: ScenicQueueTicket) {
|
||||
tagStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
if ticket.isMissRequeue {
|
||||
tagStack.addArrangedSubview(tag(text: ticket.missRequeueText, fg: ScenicQueueTokens.tagMissRequeueForeground, bg: ScenicQueueTokens.tagMissRequeueBackground))
|
||||
}
|
||||
let statusColors: (UIColor, UIColor) = ticket.status == .waiting
|
||||
? (ScenicQueueTokens.tagWaitingForeground, ScenicQueueTokens.tagWaitingBackground)
|
||||
: (ScenicQueueTokens.tagCalledForeground, ScenicQueueTokens.tagCalledBackground)
|
||||
tagStack.addArrangedSubview(tag(text: ticket.statusText, fg: statusColors.0, bg: statusColors.1))
|
||||
}
|
||||
|
||||
private func rebuildMeta(_ ticket: ScenicQueueTicket) {
|
||||
metaStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
let identityRow = UIStackView()
|
||||
identityRow.axis = .horizontal
|
||||
identityRow.spacing = 6
|
||||
if ticket.shouldShowIdentityTag {
|
||||
let colors = identityColors(ticket.identityTag)
|
||||
identityRow.addArrangedSubview(tag(text: ticket.identityTag, fg: colors.fg, bg: colors.bg))
|
||||
}
|
||||
if !ticket.queueBanLabel.isEmpty {
|
||||
identityRow.addArrangedSubview(tag(text: ticket.queueBanLabel, fg: ScenicQueueTokens.tagBanForeground, bg: ScenicQueueTokens.tagBanBackground))
|
||||
}
|
||||
if !identityRow.arrangedSubviews.isEmpty {
|
||||
metaStack.addArrangedSubview(identityRow)
|
||||
}
|
||||
metaStack.addArrangedSubview(kvRow(label: "取号时间", value: ticket.queueTimeDisplay))
|
||||
metaStack.addArrangedSubview(kvRow(label: "手机号", value: ticket.phoneMasked, tap: #selector(dialTapped)))
|
||||
metaStack.addArrangedSubview(kvRow(label: "等待", value: "\(ticket.waitMinutes)分钟", accent: true))
|
||||
metaStack.addArrangedSubview(kvRow(label: "前方", value: "\(ticket.peopleAhead)人", accent: true))
|
||||
metaStack.addArrangedSubview(kvRow(label: "排号次数", value: "\(ticket.queueCountToday)次", accent: true))
|
||||
}
|
||||
|
||||
private func rebuildActions(ticket: ScenicQueueTicket, shootingQueueNo: String?, remainSeconds: Int) {
|
||||
actionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
let skip = ScenicQueueActionButton(title: "过号", background: ScenicQueueTokens.buttonSkipBackground, foreground: ScenicQueueTokens.buttonSkipForeground)
|
||||
skip.addTarget(self, action: #selector(skipTapped), for: .touchUpInside)
|
||||
let primaryTitle: String
|
||||
if shootingQueueNo == ticket.queueNo {
|
||||
primaryTitle = "剩余 \(max(0, remainSeconds)) 秒"
|
||||
} else {
|
||||
primaryTitle = ticket.status == .called ? "开始拍摄" : "叫号"
|
||||
}
|
||||
let primary = ScenicQueueActionButton(title: primaryTitle, background: ScenicQueueTokens.bannerBlue, foreground: .white)
|
||||
primary.isEnabled = shootingQueueNo != ticket.queueNo
|
||||
primary.alpha = primary.isEnabled ? 1 : 0.65
|
||||
primary.addTarget(self, action: #selector(primaryTapped), for: .touchUpInside)
|
||||
let row = UIStackView(arrangedSubviews: [skip, primary])
|
||||
row.axis = .horizontal
|
||||
row.spacing = 10
|
||||
row.distribution = .fillProportionally
|
||||
skip.snp.makeConstraints { make in
|
||||
make.height.equalTo(46)
|
||||
make.width.equalTo(primary).multipliedBy(0.56)
|
||||
}
|
||||
actionsStack.addArrangedSubview(row)
|
||||
if ticket.status == .called, shootingQueueNo != ticket.queueNo {
|
||||
recallButton.addTarget(self, action: #selector(recallTapped), for: .touchUpInside)
|
||||
recallButton.snp.makeConstraints { make in make.height.equalTo(40) }
|
||||
actionsStack.addArrangedSubview(recallButton)
|
||||
}
|
||||
if ticket.showCompleteAction {
|
||||
completeButton.addTarget(self, action: #selector(completeTapped), for: .touchUpInside)
|
||||
completeButton.snp.makeConstraints { make in make.height.equalTo(40) }
|
||||
actionsStack.addArrangedSubview(completeButton)
|
||||
}
|
||||
}
|
||||
|
||||
private func tag(text: String, fg: UIColor, bg: UIColor) -> UILabel {
|
||||
let label = PaddingLabel(insets: UIEdgeInsets(top: 5, left: 8, bottom: 5, right: 8))
|
||||
label.font = ScenicQueueTokens.statusChipFont
|
||||
label.textColor = fg
|
||||
label.backgroundColor = bg
|
||||
label.text = text
|
||||
label.layer.cornerRadius = ScenicQueueTokens.radiusChip
|
||||
label.clipsToBounds = true
|
||||
return label
|
||||
}
|
||||
|
||||
private func identityColors(_ label: String) -> (fg: UIColor, bg: UIColor) {
|
||||
switch label.trimmingCharacters(in: .whitespacesAndNewlines) {
|
||||
case "打野":
|
||||
return (UIColor(hex: 0xF5222D), UIColor(hex: 0xFFF1F0))
|
||||
case "摄影师":
|
||||
return (UIColor(hex: 0x1890FF), UIColor(hex: 0xE6F7FF))
|
||||
default:
|
||||
return (UIColor(hex: 0x4B5563), UIColor(hex: 0xF3F4F6))
|
||||
}
|
||||
}
|
||||
|
||||
private func kvRow(label: String, value: String, accent: Bool = false, tap: Selector? = nil) -> UIView {
|
||||
let row = UIView()
|
||||
let left = UILabel()
|
||||
left.text = label
|
||||
left.font = ScenicQueueTokens.metaLabelFont
|
||||
left.textColor = AppColor.textTertiary
|
||||
let right = PaddingLabel(insets: accent ? UIEdgeInsets(top: 1, left: 6, bottom: 1, right: 6) : .zero)
|
||||
right.text = value
|
||||
right.font = accent ? ScenicQueueTokens.metaAccentFont : ScenicQueueTokens.rowLabelFont
|
||||
right.textColor = accent ? ScenicQueueTokens.bannerBlue : AppColor.textPrimary
|
||||
right.backgroundColor = accent ? AppColor.primaryLight : .clear
|
||||
right.layer.cornerRadius = 2
|
||||
right.clipsToBounds = true
|
||||
if let tap {
|
||||
right.isUserInteractionEnabled = true
|
||||
right.addGestureRecognizer(UITapGestureRecognizer(target: self, action: tap))
|
||||
}
|
||||
row.addSubview(left)
|
||||
row.addSubview(right)
|
||||
left.snp.makeConstraints { make in
|
||||
make.leading.centerY.equalToSuperview()
|
||||
make.top.bottom.equalToSuperview()
|
||||
}
|
||||
right.snp.makeConstraints { make in
|
||||
make.trailing.centerY.equalToSuperview()
|
||||
make.leading.greaterThanOrEqualTo(left.snp.trailing).offset(12)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
@objc private func dialTapped() {
|
||||
guard let ticket else { return }
|
||||
delegate?.scenicQueueTicketCellDidTapDial(self, ticket: ticket)
|
||||
}
|
||||
|
||||
@objc private func skipTapped() {
|
||||
guard let ticket else { return }
|
||||
delegate?.scenicQueueTicketCellDidTapSkip(self, ticket: ticket)
|
||||
}
|
||||
|
||||
@objc private func primaryTapped() {
|
||||
guard let ticket else { return }
|
||||
delegate?.scenicQueueTicketCellDidTapPrimary(self, ticket: ticket)
|
||||
}
|
||||
|
||||
@objc private func recallTapped() {
|
||||
guard let ticket else { return }
|
||||
delegate?.scenicQueueTicketCellDidTapRecall(self, ticket: ticket)
|
||||
}
|
||||
|
||||
@objc private func completeTapped() {
|
||||
guard let ticket else { return }
|
||||
delegate?.scenicQueueTicketCellDidTapComplete(self, ticket: ticket)
|
||||
}
|
||||
|
||||
@objc private func markTapped() {
|
||||
guard let ticket else { return }
|
||||
delegate?.scenicQueueTicketCellDidTapMark(self, ticket: ticket)
|
||||
}
|
||||
}
|
||||
|
||||
/// 过号列表 cell 代理。
|
||||
protocol ScenicQueueSkippedCellDelegate: AnyObject {
|
||||
func scenicQueueSkippedCellDidTapDial(_ cell: ScenicQueueSkippedCell, ticket: ScenicQueueSkippedTicket)
|
||||
func scenicQueueSkippedCellDidTapRequeue(_ cell: ScenicQueueSkippedCell, ticket: ScenicQueueSkippedTicket)
|
||||
}
|
||||
|
||||
/// 过号列表 cell。
|
||||
final class ScenicQueueSkippedCell: UITableViewCell {
|
||||
static let reuseIdentifier = "ScenicQueueSkippedCell"
|
||||
|
||||
weak var delegate: ScenicQueueSkippedCellDelegate?
|
||||
private let cardView = UIView()
|
||||
private let queueNoLabel = UILabel()
|
||||
private let tagStack = UIStackView()
|
||||
private let metaStack = UIStackView()
|
||||
private let requeueButton = ScenicQueueActionButton(title: "重新排队", background: ScenicQueueTokens.bannerBlue, foreground: .white)
|
||||
private var ticket: ScenicQueueSkippedTicket?
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 配置过号 cell。
|
||||
func configure(ticket: ScenicQueueSkippedTicket) {
|
||||
self.ticket = ticket
|
||||
queueNoLabel.text = ticket.queueNo
|
||||
rebuildTags(ticket)
|
||||
rebuildMeta(ticket)
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = ScenicQueueTokens.radiusCard
|
||||
cardView.layer.borderWidth = 1
|
||||
cardView.layer.borderColor = ScenicQueueTokens.cardOutline.cgColor
|
||||
contentView.addSubview(cardView)
|
||||
|
||||
queueNoLabel.font = ScenicQueueTokens.ticketIdFont
|
||||
queueNoLabel.textColor = AppColor.textPrimary
|
||||
tagStack.axis = .horizontal
|
||||
tagStack.spacing = 6
|
||||
let titleRow = UIStackView(arrangedSubviews: [queueNoLabel, UIView(), tagStack])
|
||||
titleRow.axis = .horizontal
|
||||
titleRow.alignment = .center
|
||||
|
||||
metaStack.axis = .vertical
|
||||
metaStack.spacing = 10
|
||||
requeueButton.addTarget(self, action: #selector(requeueTapped), for: .touchUpInside)
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [titleRow, metaStack, requeueButton])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 12
|
||||
cardView.addSubview(stack)
|
||||
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 5, left: 12, bottom: 5, right: 12))
|
||||
}
|
||||
stack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 14, left: 16, bottom: 14, right: 16))
|
||||
}
|
||||
requeueButton.snp.makeConstraints { make in
|
||||
make.height.equalTo(46)
|
||||
}
|
||||
}
|
||||
|
||||
private func rebuildTags(_ ticket: ScenicQueueSkippedTicket) {
|
||||
tagStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
if !ticket.queueBanLabel.isEmpty {
|
||||
tagStack.addArrangedSubview(tag(text: ticket.queueBanLabel, fg: ScenicQueueTokens.tagBanForeground, bg: ScenicQueueTokens.tagBanBackground))
|
||||
}
|
||||
if ticket.isMissRequeue {
|
||||
tagStack.addArrangedSubview(tag(text: ticket.missRequeueText, fg: ScenicQueueTokens.tagMissRequeueForeground, bg: ScenicQueueTokens.tagMissRequeueBackground))
|
||||
}
|
||||
tagStack.addArrangedSubview(tag(text: ticket.statusText, fg: ScenicQueueTokens.tagSkippedForeground, bg: ScenicQueueTokens.tagSkippedBackground))
|
||||
}
|
||||
|
||||
private func rebuildMeta(_ ticket: ScenicQueueSkippedTicket) {
|
||||
metaStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
metaStack.addArrangedSubview(kvRow(label: "手机号", value: ticket.phoneMasked, tap: #selector(dialTapped)))
|
||||
if !ticket.skippedTimeDisplay.isEmpty {
|
||||
metaStack.addArrangedSubview(kvRow(label: "过号时间", value: ticket.skippedTimeDisplay))
|
||||
}
|
||||
}
|
||||
|
||||
private func tag(text: String, fg: UIColor, bg: UIColor) -> UILabel {
|
||||
let label = PaddingLabel(insets: UIEdgeInsets(top: 5, left: 8, bottom: 5, right: 8))
|
||||
label.text = text
|
||||
label.font = ScenicQueueTokens.statusChipFont
|
||||
label.textColor = fg
|
||||
label.backgroundColor = bg
|
||||
label.layer.cornerRadius = ScenicQueueTokens.radiusChip
|
||||
label.clipsToBounds = true
|
||||
return label
|
||||
}
|
||||
|
||||
private func kvRow(label: String, value: String, tap: Selector? = nil) -> UIView {
|
||||
let row = UIView()
|
||||
let left = UILabel()
|
||||
left.text = label
|
||||
left.font = ScenicQueueTokens.metaLabelFont
|
||||
left.textColor = AppColor.textTertiary
|
||||
let right = UILabel()
|
||||
right.text = value
|
||||
right.font = ScenicQueueTokens.phoneMaskedFont
|
||||
right.textColor = AppColor.textPrimary
|
||||
if let tap {
|
||||
right.isUserInteractionEnabled = true
|
||||
right.addGestureRecognizer(UITapGestureRecognizer(target: self, action: tap))
|
||||
}
|
||||
row.addSubview(left)
|
||||
row.addSubview(right)
|
||||
left.snp.makeConstraints { make in
|
||||
make.leading.centerY.equalToSuperview()
|
||||
make.top.bottom.equalToSuperview()
|
||||
}
|
||||
right.snp.makeConstraints { make in
|
||||
make.trailing.centerY.equalToSuperview()
|
||||
make.leading.greaterThanOrEqualTo(left.snp.trailing).offset(12)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
@objc private func dialTapped() {
|
||||
guard let ticket else { return }
|
||||
delegate?.scenicQueueSkippedCellDidTapDial(self, ticket: ticket)
|
||||
}
|
||||
|
||||
@objc private func requeueTapped() {
|
||||
guard let ticket else { return }
|
||||
delegate?.scenicQueueSkippedCellDidTapRequeue(self, ticket: ticket)
|
||||
}
|
||||
}
|
||||
|
||||
/// 带内边距的 UILabel。
|
||||
final class PaddingLabel: UILabel {
|
||||
private let insets: UIEdgeInsets
|
||||
|
||||
init(insets: UIEdgeInsets) {
|
||||
self.insets = insets
|
||||
super.init(frame: .zero)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func drawText(in rect: CGRect) {
|
||||
super.drawText(in: rect.inset(by: insets))
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
let size = super.intrinsicContentSize
|
||||
return CGSize(width: size.width + insets.left + insets.right, height: size.height + insets.top + insets.bottom)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user