Files
suixinkan_uikit/suixinkan/UI/ScenicQueue/ScenicQueueViewController.swift
汉秋 6336feff27 完善景区排队设置页与全局按钮配置迁移。
重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 16:21:53 +08:00

443 lines
19 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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 tabStrip = ScenicQueueTabStripView(titles: ["当前排队", "过号列表"])
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 dataSource: UITableViewDiffableDataSource<Int, ScenicQueueListItem>!
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(named: "queue_location"))
locationIcon.contentMode = .scaleAspectFit
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.layer.shadowColor = UIColor.black.cgColor
contentContainer.layer.shadowOpacity = 0.08
contentContainer.layer.shadowRadius = 4
contentContainer.layer.shadowOffset = CGSize(width: 0, height: 2)
tableView.backgroundColor = .white
tableView.separatorStyle = .none
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 260
tableView.delegate = 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)
configureDataSource()
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(tabStrip)
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)
}
tabStrip.snp.makeConstraints { make in
make.top.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(12)
make.height.equalTo(48)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(tabStrip.snp.bottom)
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() {
tabStrip.onSelect = { [weak self] index in self?.selectTab(index) }
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()
applySnapshot(animated: true)
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 configureDataSource() {
dataSource = UITableViewDiffableDataSource<Int, ScenicQueueListItem>(tableView: tableView) { [weak self] tableView, indexPath, item in
guard let self else { return UITableViewCell() }
switch item {
case .current(let ticket):
let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueTicketCell.reuseIdentifier, for: indexPath) as! ScenicQueueTicketCell
cell.delegate = self
cell.configure(
ticket: ticket,
shootingQueueNo: self.viewModel.shootingTimedQueueNo,
remainSeconds: self.viewModel.shootingRemainSeconds,
showStartShootingButton: self.viewModel.showStartShootingButton
)
return cell
case .skipped(let ticket):
let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueSkippedCell.reuseIdentifier, for: indexPath) as! ScenicQueueSkippedCell
cell.delegate = self
cell.configure(ticket: ticket)
return cell
}
}
}
private func applySnapshot(animated: Bool) {
let previousItems = Set(dataSource.snapshot().itemIdentifiers)
var snapshot = NSDiffableDataSourceSnapshot<Int, ScenicQueueListItem>()
snapshot.appendSections([0])
let items: [ScenicQueueListItem] = selectedTab == 0
? viewModel.currentQueue.map(ScenicQueueListItem.current)
: viewModel.skippedQueue.map(ScenicQueueListItem.skipped)
snapshot.appendItems(items)
snapshot.reconfigureItems(items.filter(previousItems.contains))
dataSource.apply(snapshot, animatingDifferences: animated)
}
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 dialog = ScenicQueueConfirmationDialogViewController(
configuration: ScenicQueueDialogConfiguration(
title: "请先设置排队参数",
message: "请在排队设置中选择并保存打卡点后,再使用排队管理。",
emphasizedText: nil,
dangerText: nil,
cancelTitle: "暂不使用",
confirmTitle: "去设置",
buttonRadius: 22,
dismissOnBackdrop: false
),
onCancel: { [weak self] in self?.viewModel.exitQueueModule() },
onConfirm: { [weak self] in self?.navigationController?.pushViewController(ScenicQueueSettingsViewController(), animated: true) }
)
present(dialog, animated: true)
}
private func presentConfirm(
title: String,
emphasizedText: String,
dangerText: String? = nil,
confirmTitle: String = "确认",
cancel: @escaping () -> Void,
confirm: @escaping () -> Void
) {
let dialog = ScenicQueueConfirmationDialogViewController(
configuration: ScenicQueueDialogConfiguration(
title: title,
message: nil,
emphasizedText: emphasizedText,
dangerText: dangerText,
cancelTitle: "取消",
confirmTitle: confirmTitle,
buttonRadius: title == "确认完成拍摄" ? 22 : 8,
dismissOnBackdrop: true
),
onCancel: cancel,
onConfirm: confirm
)
present(dialog, animated: true)
}
private func openDial(_ digits: String) {
guard !digits.isEmpty else { return }
present(ScenicQueueDialSheetViewController(digits: digits), animated: true)
}
@objc private func settingsTapped() {
viewModel.openSettings()
}
private func selectTab(_ index: Int) {
guard index != selectedTab else { return }
selectedTab = index
UIView.transition(with: tableView, duration: 0.22, options: .transitionCrossDissolve) {
self.applySnapshot(animated: false)
}
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: UITableViewDelegate {
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: "确认过号", emphasizedText: ticket.queueNo, dangerText: "确认该用户未到场并过号", cancel: { [weak self] in
self?.viewModel.dismissSkipDialog()
}) { [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: "确认叫号", emphasizedText: "确认叫号【\(ticket.queueNo)", cancel: { [weak self] in
self?.viewModel.dismissCallConfirmDialog()
}) { [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: "重新叫号", emphasizedText: "确认叫号【\(ticket.queueNo)", cancel: { [weak self] in
self?.viewModel.dismissCallConfirmDialog()
}) { [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: "确认完成拍摄", emphasizedText: ticket.queueNo, cancel: { [weak self] in
self?.viewModel.dismissFinishDialog()
}) { [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)
guard let pending = viewModel.pendingMark else { return }
let dialog = ScenicQueueMarkDialogViewController(
pending: pending,
onCancel: { [weak self] in self?.viewModel.dismissMarkDialog() },
onConfirm: { [weak self] marked, days in
guard let self else { return }
Task { await self.viewModel.confirmUserMark(api: self.api, markAsFreelancePhotog: marked, queueBanDays: days) }
}
)
present(dialog, 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: "确认重新排队", emphasizedText: ticket.queueNo, dangerText: "确认后将插入当前排队队列", confirmTitle: "确定", cancel: { [weak self] in
self?.viewModel.dismissRequeueDialog()
}) { [weak self] in
guard let self else { return }
Task { await self.viewModel.confirmRequeueTicket(api: self.api) }
}
}
}