新增景区排队管理功能

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

View File

@ -0,0 +1,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) }
}
}
}