// // 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! 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(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() 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) } } } }