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