Files
suixinkan_uikit/suixinkan/UI/ScenicQueue/ScenicQueueViewController.swift
2026-07-07 15:32:25 +08:00

415 lines
18 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 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) }
}
}
}