新增景区排队管理功能

This commit is contained in:
2026-07-07 15:32:25 +08:00
parent 854a66689f
commit 0aa8b14e1f
20 changed files with 4642 additions and 1 deletions

View File

@ -0,0 +1,543 @@
//
// 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 skip = ScenicQueueActionButton(title: "过号", background: ScenicQueueTokens.buttonSkipBackground, foreground: ScenicQueueTokens.buttonSkipForeground)
skip.addTarget(self, action: #selector(skipTapped), for: .touchUpInside)
let primaryTitle: String
if shootingQueueNo == ticket.queueNo {
primaryTitle = "剩余 \(max(0, remainSeconds))"
} else {
primaryTitle = ticket.status == .called ? "开始拍摄" : "叫号"
}
let primary = ScenicQueueActionButton(title: primaryTitle, background: ScenicQueueTokens.bannerBlue, foreground: .white)
primary.isEnabled = shootingQueueNo != ticket.queueNo
primary.alpha = primary.isEnabled ? 1 : 0.65
primary.addTarget(self, action: #selector(primaryTapped), for: .touchUpInside)
let row = UIStackView(arrangedSubviews: [skip, primary])
row.axis = .horizontal
row.spacing = 10
row.distribution = .fillProportionally
skip.snp.makeConstraints { make in
make.height.equalTo(46)
make.width.equalTo(primary).multipliedBy(0.56)
}
actionsStack.addArrangedSubview(row)
if ticket.status == .called, shootingQueueNo != ticket.queueNo {
recallButton.addTarget(self, action: #selector(recallTapped), for: .touchUpInside)
recallButton.snp.makeConstraints { make in make.height.equalTo(40) }
actionsStack.addArrangedSubview(recallButton)
}
if ticket.showCompleteAction {
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
if let tap {
right.isUserInteractionEnabled = true
right.addGestureRecognizer(UITapGestureRecognizer(target: self, action: tap))
}
row.addSubview(left)
row.addSubview(right)
left.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
make.top.bottom.equalToSuperview()
}
right.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
if let tap {
right.isUserInteractionEnabled = true
right.addGestureRecognizer(UITapGestureRecognizer(target: self, action: tap))
}
row.addSubview(left)
row.addSubview(right)
left.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
make.top.bottom.equalToSuperview()
}
right.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)
}
}