// // ScenicQueueViews.swift // suixinkan // import SnapKit import UIKit /// 排队统计 Banner。 final class ScenicQueueStatsBannerView: UIView { private let peopleValueLabel = UILabel() private let avgValueLabel = UILabel() init() { super.init(frame: .zero) setupUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 更新统计。 func configure(summary: ScenicQueueSummary) { peopleValueLabel.text = "\(summary.peopleInQueue)" avgValueLabel.text = "\(summary.avgWaitMinutes)" } private func setupUI() { backgroundColor = ScenicQueueTokens.bannerBlue let left = metricColumn(title: "在排人数", valueLabel: peopleValueLabel, unit: "人") let right = metricColumn(title: "平均等待", valueLabel: avgValueLabel, unit: "分钟") let divider = UIView() divider.backgroundColor = ScenicQueueTokens.bannerDivider addSubview(left) addSubview(divider) addSubview(right) left.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview() make.trailing.equalTo(divider.snp.leading) make.width.equalTo(right) } divider.snp.makeConstraints { make in make.centerX.equalToSuperview() make.centerY.equalToSuperview() make.width.equalTo(0.8) make.height.equalTo(42) } right.snp.makeConstraints { make in make.leading.equalTo(divider.snp.trailing) make.trailing.top.bottom.equalToSuperview() } } private func metricColumn(title: String, valueLabel: UILabel, unit: String) -> UIView { let container = UIView() let titleLabel = UILabel() titleLabel.text = title titleLabel.textColor = ScenicQueueTokens.bannerMutedLabel titleLabel.font = ScenicQueueTokens.bannerLabelFont titleLabel.textAlignment = .center valueLabel.textColor = .white valueLabel.font = ScenicQueueTokens.bannerValueFont valueLabel.textAlignment = .center let unitLabel = UILabel() unitLabel.text = unit unitLabel.textColor = .white unitLabel.font = ScenicQueueTokens.bannerUnitFont let valueStack = UIStackView(arrangedSubviews: [valueLabel, unitLabel]) valueStack.axis = .horizontal valueStack.alignment = .lastBaseline valueStack.spacing = 2 let stack = UIStackView(arrangedSubviews: [titleLabel, valueStack]) stack.axis = .vertical stack.alignment = .center stack.spacing = 6 container.addSubview(stack) stack.snp.makeConstraints { make in make.center.equalToSuperview() } return container } } /// 排队列表标签。 final class ScenicQueueTagLabel: UILabel { init() { super.init(frame: .zero) font = ScenicQueueTokens.statusChipFont textAlignment = .center layer.cornerRadius = ScenicQueueTokens.radiusChip clipsToBounds = true } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 设置标签文案与颜色。 func configure(text: String, foreground: UIColor, background: UIColor) { self.text = text textColor = foreground backgroundColor = background } } /// 排队操作按钮。 final class ScenicQueueActionButton: UIButton { init(title: String, background: UIColor, foreground: UIColor) { super.init(frame: .zero) setTitle(title, for: .normal) titleLabel?.font = ScenicQueueTokens.buttonPrimaryFont setTitleColor(foreground, for: .normal) backgroundColor = background layer.cornerRadius = ScenicQueueTokens.radiusPrimaryRowButton clipsToBounds = true } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } /// 当前排队 cell 交互代理。 protocol ScenicQueueTicketCellDelegate: AnyObject { func scenicQueueTicketCellDidTapDial(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) func scenicQueueTicketCellDidTapSkip(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) func scenicQueueTicketCellDidTapPrimary(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) func scenicQueueTicketCellDidTapRecall(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) func scenicQueueTicketCellDidTapComplete(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) func scenicQueueTicketCellDidTapMark(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) } /// 当前排队列表 cell。 final class ScenicQueueTicketCell: UITableViewCell { static let reuseIdentifier = "ScenicQueueTicketCell" weak var delegate: ScenicQueueTicketCellDelegate? private let cardView = UIView() private let queueNoLabel = UILabel() private let markButton = UIButton(type: .system) private let tagStack = UIStackView() private let metaStack = UIStackView() private let actionsStack = UIStackView() private let recallButton = ScenicQueueActionButton(title: "重新叫号", background: ScenicQueueTokens.buttonCompleteBackground, foreground: ScenicQueueTokens.bannerBlue) private let completeButton = ScenicQueueActionButton(title: "完成", background: ScenicQueueTokens.buttonCompleteBackground, foreground: ScenicQueueTokens.bannerBlue) private var ticket: ScenicQueueTicket? private var showStartShootingButton = true override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) setupUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 配置当前排队 cell。 func configure(ticket: ScenicQueueTicket, shootingQueueNo: String?, remainSeconds: Int, showStartShootingButton: Bool) { self.ticket = ticket self.showStartShootingButton = showStartShootingButton queueNoLabel.text = ticket.queueNo markButton.isHidden = ticket.markAsPhotog == 1 rebuildTags(ticket) rebuildMeta(ticket) rebuildActions(ticket: ticket, shootingQueueNo: shootingQueueNo, remainSeconds: remainSeconds) } private func setupUI() { selectionStyle = .none backgroundColor = .clear contentView.backgroundColor = .clear cardView.backgroundColor = .white cardView.layer.cornerRadius = ScenicQueueTokens.radiusCard cardView.layer.borderWidth = 1 cardView.layer.borderColor = ScenicQueueTokens.cardOutline.cgColor cardView.clipsToBounds = true contentView.addSubview(cardView) queueNoLabel.font = ScenicQueueTokens.ticketIdFont queueNoLabel.textColor = AppColor.textPrimary markButton.setTitle("标记", for: .normal) markButton.titleLabel?.font = ScenicQueueTokens.rowLabelFont markButton.setTitleColor(ScenicQueueTokens.bannerBlue, for: .normal) markButton.addTarget(self, action: #selector(markTapped), for: .touchUpInside) let titleRow = UIStackView(arrangedSubviews: [queueNoLabel, markButton, UIView(), tagStack]) titleRow.axis = .horizontal titleRow.alignment = .center titleRow.spacing = 4 tagStack.axis = .horizontal tagStack.spacing = 6 tagStack.alignment = .center metaStack.axis = .vertical metaStack.spacing = 10 actionsStack.axis = .vertical actionsStack.spacing = 10 let stack = UIStackView(arrangedSubviews: [titleRow, metaStack, actionsStack]) stack.axis = .vertical stack.spacing = 12 cardView.addSubview(stack) cardView.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 5, left: 12, bottom: 5, right: 12)) } stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 14, left: 16, bottom: 14, right: 16)) } } private func rebuildTags(_ ticket: ScenicQueueTicket) { tagStack.arrangedSubviews.forEach { $0.removeFromSuperview() } if ticket.isMissRequeue { tagStack.addArrangedSubview(tag(text: ticket.missRequeueText, fg: ScenicQueueTokens.tagMissRequeueForeground, bg: ScenicQueueTokens.tagMissRequeueBackground)) } let statusColors: (UIColor, UIColor) = ticket.status == .waiting ? (ScenicQueueTokens.tagWaitingForeground, ScenicQueueTokens.tagWaitingBackground) : (ScenicQueueTokens.tagCalledForeground, ScenicQueueTokens.tagCalledBackground) tagStack.addArrangedSubview(tag(text: ticket.statusText, fg: statusColors.0, bg: statusColors.1)) } private func rebuildMeta(_ ticket: ScenicQueueTicket) { metaStack.arrangedSubviews.forEach { $0.removeFromSuperview() } let identityRow = UIStackView() identityRow.axis = .horizontal identityRow.spacing = 6 if ticket.shouldShowIdentityTag { let colors = identityColors(ticket.identityTag) identityRow.addArrangedSubview(tag(text: ticket.identityTag, fg: colors.fg, bg: colors.bg)) } if !ticket.queueBanLabel.isEmpty { identityRow.addArrangedSubview(tag(text: ticket.queueBanLabel, fg: ScenicQueueTokens.tagBanForeground, bg: ScenicQueueTokens.tagBanBackground)) } if !identityRow.arrangedSubviews.isEmpty { metaStack.addArrangedSubview(identityRow) } metaStack.addArrangedSubview(kvRow(label: "取号时间", value: ticket.queueTimeDisplay)) metaStack.addArrangedSubview(kvRow(label: "手机号", value: ticket.phoneMasked, tap: #selector(dialTapped))) metaStack.addArrangedSubview(kvRow(label: "等待", value: "\(ticket.waitMinutes)分钟", accent: true)) metaStack.addArrangedSubview(kvRow(label: "前方", value: "\(ticket.peopleAhead)人", accent: true)) metaStack.addArrangedSubview(kvRow(label: "排号次数", value: "\(ticket.queueCountToday)次", accent: true)) } private func rebuildActions(ticket: ScenicQueueTicket, shootingQueueNo: String?, remainSeconds: Int) { actionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() } let state = ScenicQueueTicketActionState.make( ticket: ticket, shootingQueueNo: shootingQueueNo, remainSeconds: remainSeconds, showStartShootingButton: showStartShootingButton ) let skip = ScenicQueueActionButton(title: "过号", background: ScenicQueueTokens.buttonSkipBackground, foreground: ScenicQueueTokens.buttonSkipForeground) skip.addTarget(self, action: #selector(skipTapped), for: .touchUpInside) skip.isEnabled = state.skipEnabled skip.alpha = state.skipEnabled ? 1 : 0.45 let row = UIStackView(arrangedSubviews: [skip]) row.axis = .horizontal row.spacing = 10 row.distribution = .fillProportionally if state.primaryVisible { let primary = ScenicQueueActionButton(title: state.primaryTitle, background: ScenicQueueTokens.bannerBlue, foreground: .white) primary.isEnabled = state.primaryEnabled primary.alpha = state.primaryEnabled ? 1 : 0.65 primary.addTarget(self, action: #selector(primaryTapped), for: .touchUpInside) row.addArrangedSubview(primary) skip.snp.makeConstraints { $0.width.equalTo(primary).multipliedBy(0.56) } } row.snp.makeConstraints { $0.height.equalTo(46) } actionsStack.addArrangedSubview(row) recallButton.removeTarget(nil, action: nil, for: .allEvents) completeButton.removeTarget(nil, action: nil, for: .allEvents) if state.recallVisible { recallButton.addTarget(self, action: #selector(recallTapped), for: .touchUpInside) recallButton.snp.makeConstraints { make in make.height.equalTo(40) } actionsStack.addArrangedSubview(recallButton) } if state.completeVisible { completeButton.addTarget(self, action: #selector(completeTapped), for: .touchUpInside) completeButton.snp.makeConstraints { make in make.height.equalTo(40) } actionsStack.addArrangedSubview(completeButton) } } private func tag(text: String, fg: UIColor, bg: UIColor) -> UILabel { let label = PaddingLabel(insets: UIEdgeInsets(top: 5, left: 8, bottom: 5, right: 8)) label.font = ScenicQueueTokens.statusChipFont label.textColor = fg label.backgroundColor = bg label.text = text label.layer.cornerRadius = ScenicQueueTokens.radiusChip label.clipsToBounds = true return label } private func identityColors(_ label: String) -> (fg: UIColor, bg: UIColor) { switch label.trimmingCharacters(in: .whitespacesAndNewlines) { case "打野": return (UIColor(hex: 0xF5222D), UIColor(hex: 0xFFF1F0)) case "摄影师": return (UIColor(hex: 0x1890FF), UIColor(hex: 0xE6F7FF)) default: return (UIColor(hex: 0x4B5563), UIColor(hex: 0xF3F4F6)) } } private func kvRow(label: String, value: String, accent: Bool = false, tap: Selector? = nil) -> UIView { let row = UIView() let left = UILabel() left.text = label left.font = ScenicQueueTokens.metaLabelFont left.textColor = AppColor.textTertiary let right = PaddingLabel(insets: accent ? UIEdgeInsets(top: 1, left: 6, bottom: 1, right: 6) : .zero) right.text = value right.font = accent ? ScenicQueueTokens.metaAccentFont : ScenicQueueTokens.rowLabelFont right.textColor = accent ? ScenicQueueTokens.bannerBlue : AppColor.textPrimary right.backgroundColor = accent ? AppColor.primaryLight : .clear right.layer.cornerRadius = 2 right.clipsToBounds = true row.addSubview(left) let valueContainer = UIStackView(arrangedSubviews: [right]) valueContainer.axis = .horizontal valueContainer.alignment = .center valueContainer.spacing = 8 if let tap { let dial = UIButton(type: .system) dial.backgroundColor = ScenicQueueTokens.bannerBlue dial.tintColor = .white dial.setImage(UIImage(systemName: "phone.fill")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)), for: .normal) dial.layer.cornerRadius = 16 dial.addTarget(self, action: tap, for: .touchUpInside) valueContainer.addArrangedSubview(dial) dial.snp.makeConstraints { $0.size.equalTo(32) } } row.addSubview(valueContainer) left.snp.makeConstraints { make in make.leading.centerY.equalToSuperview() make.top.bottom.equalToSuperview() } valueContainer.snp.makeConstraints { make in make.trailing.centerY.equalToSuperview() make.leading.greaterThanOrEqualTo(left.snp.trailing).offset(12) } return row } @objc private func dialTapped() { guard let ticket else { return } delegate?.scenicQueueTicketCellDidTapDial(self, ticket: ticket) } @objc private func skipTapped() { guard let ticket else { return } delegate?.scenicQueueTicketCellDidTapSkip(self, ticket: ticket) } @objc private func primaryTapped() { guard let ticket else { return } delegate?.scenicQueueTicketCellDidTapPrimary(self, ticket: ticket) } @objc private func recallTapped() { guard let ticket else { return } delegate?.scenicQueueTicketCellDidTapRecall(self, ticket: ticket) } @objc private func completeTapped() { guard let ticket else { return } delegate?.scenicQueueTicketCellDidTapComplete(self, ticket: ticket) } @objc private func markTapped() { guard let ticket else { return } delegate?.scenicQueueTicketCellDidTapMark(self, ticket: ticket) } } /// 过号列表 cell 代理。 protocol ScenicQueueSkippedCellDelegate: AnyObject { func scenicQueueSkippedCellDidTapDial(_ cell: ScenicQueueSkippedCell, ticket: ScenicQueueSkippedTicket) func scenicQueueSkippedCellDidTapRequeue(_ cell: ScenicQueueSkippedCell, ticket: ScenicQueueSkippedTicket) } /// 过号列表 cell。 final class ScenicQueueSkippedCell: UITableViewCell { static let reuseIdentifier = "ScenicQueueSkippedCell" weak var delegate: ScenicQueueSkippedCellDelegate? private let cardView = UIView() private let queueNoLabel = UILabel() private let tagStack = UIStackView() private let metaStack = UIStackView() private let requeueButton = ScenicQueueActionButton(title: "重新排队", background: ScenicQueueTokens.bannerBlue, foreground: .white) private var ticket: ScenicQueueSkippedTicket? override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) setupUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 配置过号 cell。 func configure(ticket: ScenicQueueSkippedTicket) { self.ticket = ticket queueNoLabel.text = ticket.queueNo rebuildTags(ticket) rebuildMeta(ticket) } private func setupUI() { selectionStyle = .none backgroundColor = .clear cardView.backgroundColor = .white cardView.layer.cornerRadius = ScenicQueueTokens.radiusCard cardView.layer.borderWidth = 1 cardView.layer.borderColor = ScenicQueueTokens.cardOutline.cgColor contentView.addSubview(cardView) queueNoLabel.font = ScenicQueueTokens.ticketIdFont queueNoLabel.textColor = AppColor.textPrimary tagStack.axis = .horizontal tagStack.spacing = 6 let titleRow = UIStackView(arrangedSubviews: [queueNoLabel, UIView(), tagStack]) titleRow.axis = .horizontal titleRow.alignment = .center metaStack.axis = .vertical metaStack.spacing = 10 requeueButton.addTarget(self, action: #selector(requeueTapped), for: .touchUpInside) let stack = UIStackView(arrangedSubviews: [titleRow, metaStack, requeueButton]) stack.axis = .vertical stack.spacing = 12 cardView.addSubview(stack) cardView.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 5, left: 12, bottom: 5, right: 12)) } stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 14, left: 16, bottom: 14, right: 16)) } requeueButton.snp.makeConstraints { make in make.height.equalTo(46) } } private func rebuildTags(_ ticket: ScenicQueueSkippedTicket) { tagStack.arrangedSubviews.forEach { $0.removeFromSuperview() } if !ticket.queueBanLabel.isEmpty { tagStack.addArrangedSubview(tag(text: ticket.queueBanLabel, fg: ScenicQueueTokens.tagBanForeground, bg: ScenicQueueTokens.tagBanBackground)) } if ticket.isMissRequeue { tagStack.addArrangedSubview(tag(text: ticket.missRequeueText, fg: ScenicQueueTokens.tagMissRequeueForeground, bg: ScenicQueueTokens.tagMissRequeueBackground)) } tagStack.addArrangedSubview(tag(text: ticket.statusText, fg: ScenicQueueTokens.tagSkippedForeground, bg: ScenicQueueTokens.tagSkippedBackground)) } private func rebuildMeta(_ ticket: ScenicQueueSkippedTicket) { metaStack.arrangedSubviews.forEach { $0.removeFromSuperview() } metaStack.addArrangedSubview(kvRow(label: "手机号", value: ticket.phoneMasked, tap: #selector(dialTapped))) if !ticket.skippedTimeDisplay.isEmpty { metaStack.addArrangedSubview(kvRow(label: "过号时间", value: ticket.skippedTimeDisplay)) } } private func tag(text: String, fg: UIColor, bg: UIColor) -> UILabel { let label = PaddingLabel(insets: UIEdgeInsets(top: 5, left: 8, bottom: 5, right: 8)) label.text = text label.font = ScenicQueueTokens.statusChipFont label.textColor = fg label.backgroundColor = bg label.layer.cornerRadius = ScenicQueueTokens.radiusChip label.clipsToBounds = true return label } private func kvRow(label: String, value: String, tap: Selector? = nil) -> UIView { let row = UIView() let left = UILabel() left.text = label left.font = ScenicQueueTokens.metaLabelFont left.textColor = AppColor.textTertiary let right = UILabel() right.text = value right.font = ScenicQueueTokens.phoneMaskedFont right.textColor = AppColor.textPrimary row.addSubview(left) let valueContainer = UIStackView(arrangedSubviews: [right]) valueContainer.axis = .horizontal valueContainer.alignment = .center valueContainer.spacing = 8 if let tap { let dial = UIButton(type: .system) dial.backgroundColor = ScenicQueueTokens.bannerBlue dial.tintColor = .white dial.setImage(UIImage(systemName: "phone.fill")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)), for: .normal) dial.layer.cornerRadius = 16 dial.addTarget(self, action: tap, for: .touchUpInside) valueContainer.addArrangedSubview(dial) dial.snp.makeConstraints { $0.size.equalTo(32) } } row.addSubview(valueContainer) left.snp.makeConstraints { make in make.leading.centerY.equalToSuperview() make.top.bottom.equalToSuperview() } valueContainer.snp.makeConstraints { make in make.trailing.centerY.equalToSuperview() make.leading.greaterThanOrEqualTo(left.snp.trailing).offset(12) } return row } @objc private func dialTapped() { guard let ticket else { return } delegate?.scenicQueueSkippedCellDidTapDial(self, ticket: ticket) } @objc private func requeueTapped() { guard let ticket else { return } delegate?.scenicQueueSkippedCellDidTapRequeue(self, ticket: ticket) } } /// 带内边距的 UILabel。 final class PaddingLabel: UILabel { private let insets: UIEdgeInsets init(insets: UIEdgeInsets) { self.insets = insets super.init(frame: .zero) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func drawText(in rect: CGRect) { super.drawText(in: rect.inset(by: insets)) } override var intrinsicContentSize: CGSize { let size = super.intrinsicContentSize return CGSize(width: size.width + insets.left + insets.right, height: size.height + insets.top + insets.bottom) } }