重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
443 lines
19 KiB
Swift
443 lines
19 KiB
Swift
//
|
||
// 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) }
|
||
}
|
||
}
|
||
}
|