完善景区排队设置页与全局按钮配置迁移。
重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -14,4 +14,12 @@ extension UIButton {
|
||||
updatedConfiguration.contentInsets = contentInsets
|
||||
configuration = updatedConfiguration
|
||||
}
|
||||
|
||||
/// 设置图片相对标题的位置和间距,并保留按钮已有的 configuration 内容。
|
||||
func setConfigurationImagePlacement(_ imagePlacement: NSDirectionalRectEdge, padding: CGFloat) {
|
||||
var updatedConfiguration = configuration ?? .plain()
|
||||
updatedConfiguration.imagePlacement = imagePlacement
|
||||
updatedConfiguration.imagePadding = padding
|
||||
configuration = updatedConfiguration
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,9 +20,9 @@ final class ScenicSelectionViewController: BaseViewController, UITableViewDataSo
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let emptyLabel = UILabel()
|
||||
|
||||
init(viewModel: ScenicSelectionViewModel = ScenicSelectionViewModel(), homeAPI: HomeAPI = NetworkServices.shared.homeAPI) {
|
||||
init(viewModel: ScenicSelectionViewModel = ScenicSelectionViewModel(), homeAPI: HomeAPI? = nil) {
|
||||
self.viewModel = viewModel
|
||||
self.homeAPI = homeAPI
|
||||
self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
@ -58,8 +58,7 @@ final class HomeWorkStatusCardView: UIView {
|
||||
let reminderIconConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
|
||||
reminderButton.setImage(UIImage(systemName: "bell.fill", withConfiguration: reminderIconConfig), for: .normal)
|
||||
reminderButton.semanticContentAttribute = .forceLeftToRight
|
||||
reminderButton.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 6)
|
||||
reminderButton.titleEdgeInsets = UIEdgeInsets(top: 0, left: 6, bottom: 0, right: -6)
|
||||
reminderButton.setConfigurationImagePlacement(.leading, padding: 6)
|
||||
reminderButton.titleLabel?.adjustsFontSizeToFitWidth = true
|
||||
reminderButton.titleLabel?.minimumScaleFactor = 0.8
|
||||
reminderButton.addTarget(self, action: #selector(reminderTapped), for: .touchUpInside)
|
||||
|
||||
@ -38,11 +38,11 @@ final class InviteRecordViewController: BaseViewController {
|
||||
|
||||
/// 初始化邀请记录页面。
|
||||
init(
|
||||
inviteAPI: InviteServing = NetworkServices.shared.inviteAPI,
|
||||
walletAPI: WalletServing = NetworkServices.shared.walletAPI
|
||||
inviteAPI: InviteServing? = nil,
|
||||
walletAPI: WalletServing? = nil
|
||||
) {
|
||||
self.inviteAPI = inviteAPI
|
||||
self.walletAPI = walletAPI
|
||||
self.inviteAPI = inviteAPI ?? NetworkServices.shared.inviteAPI
|
||||
self.walletAPI = walletAPI ?? NetworkServices.shared.walletAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
@ -19,8 +19,8 @@ final class PhotographerInviteViewController: BaseViewController {
|
||||
private let ruleStack = UIStackView()
|
||||
|
||||
/// 初始化摄影师邀请页面。
|
||||
init(inviteAPI: InviteServing = NetworkServices.shared.inviteAPI) {
|
||||
self.inviteAPI = inviteAPI
|
||||
init(inviteAPI: InviteServing? = nil) {
|
||||
self.inviteAPI = inviteAPI ?? NetworkServices.shared.inviteAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@ -234,7 +234,7 @@ final class PhotographerInviteViewController: BaseViewController {
|
||||
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
button.setImage(UIImage(named: "ic_invite_record")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
button.tintColor = AppColor.primary
|
||||
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -6, bottom: 0, right: 6)
|
||||
button.setConfigurationImagePlacement(.leading, padding: 6)
|
||||
button.addTarget(self, action: #selector(inviteRecordTapped), for: .touchUpInside)
|
||||
container.addSubview(button)
|
||||
button.snp.makeConstraints { make in
|
||||
@ -360,8 +360,7 @@ private final class InviteIconButton: UIButton {
|
||||
setImage(UIImage(named: imageName)?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
tintColor = .white
|
||||
titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
imageEdgeInsets = UIEdgeInsets(top: 0, left: -3, bottom: 0, right: 3)
|
||||
titleEdgeInsets = UIEdgeInsets(top: 0, left: 3, bottom: 0, right: -3)
|
||||
setConfigurationImagePlacement(.leading, padding: 6)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
|
||||
@ -207,7 +207,7 @@ final class LiveAlbumListViewController: BaseViewController, UITableViewDelegate
|
||||
button.titleLabel?.font = .systemFont(ofSize: 13)
|
||||
button.setImage(UIImage(systemName: "calendar"), for: .normal)
|
||||
button.tintColor = AppColor.textPrimary
|
||||
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 4)
|
||||
button.setConfigurationImagePlacement(.leading, padding: 4)
|
||||
setDateButtonTitle(button, text: title)
|
||||
}
|
||||
|
||||
|
||||
@ -75,7 +75,7 @@ final class LiveActionButton: UIButton {
|
||||
layer.cornerRadius = 8
|
||||
clipsToBounds = true
|
||||
tintColor = .white
|
||||
imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 4)
|
||||
setConfigurationImagePlacement(.leading, padding: 4)
|
||||
updateAppearance()
|
||||
}
|
||||
|
||||
|
||||
@ -20,10 +20,10 @@ final class LocationReportHistoryViewController: BaseViewController, UITableView
|
||||
|
||||
init(
|
||||
viewModel: LocationReportHistoryViewModel = LocationReportHistoryViewModel(),
|
||||
homeAPI: HomeAPI = NetworkServices.shared.homeAPI
|
||||
homeAPI: HomeAPI? = nil
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.homeAPI = homeAPI
|
||||
self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
@ -24,10 +24,10 @@ final class LocationReportViewController: BaseViewController {
|
||||
|
||||
init(
|
||||
viewModel: LocationReportViewModel = LocationReportViewModel(),
|
||||
homeAPI: HomeAPI = NetworkServices.shared.homeAPI
|
||||
homeAPI: HomeAPI? = nil
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.homeAPI = homeAPI
|
||||
self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
@ -30,10 +30,10 @@ final class MaterialDetailViewController: BaseViewController {
|
||||
/// 初始化素材详情页。
|
||||
init(
|
||||
materialId: Int,
|
||||
api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI
|
||||
api: (any MaterialManagementServing)? = nil
|
||||
) {
|
||||
viewModel = MaterialDetailViewModel(materialId: materialId)
|
||||
self.api = api
|
||||
self.api = api ?? NetworkServices.shared.materialManagementAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
@ -49,12 +49,12 @@ final class MaterialFormViewController: BaseViewController {
|
||||
/// 初始化素材表单页。
|
||||
init(
|
||||
mode: MaterialFormViewModel.Mode,
|
||||
api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI,
|
||||
uploader: any MaterialOSSUploading = NetworkServices.shared.ossUploadService
|
||||
api: (any MaterialManagementServing)? = nil,
|
||||
uploader: (any MaterialOSSUploading)? = nil
|
||||
) {
|
||||
viewModel = MaterialFormViewModel(mode: mode)
|
||||
self.api = api
|
||||
self.uploader = uploader
|
||||
self.api = api ?? NetworkServices.shared.materialManagementAPI
|
||||
self.uploader = uploader ?? NetworkServices.shared.ossUploadService
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
@ -33,10 +33,10 @@ final class MaterialListViewController: BaseViewController {
|
||||
/// 初始化素材管理列表页。
|
||||
init(
|
||||
viewModel: MaterialListViewModel = MaterialListViewModel(),
|
||||
api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI
|
||||
api: (any MaterialManagementServing)? = nil
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.api = api
|
||||
self.api = api ?? NetworkServices.shared.materialManagementAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
@ -25,10 +25,10 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate
|
||||
/// 初始化消息中心列表页。
|
||||
init(
|
||||
viewModel: MessageCenterViewModel = MessageCenterViewModel(),
|
||||
api: any MessageCenterServing = NetworkServices.shared.messageCenterAPI
|
||||
api: (any MessageCenterServing)? = nil
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.api = api
|
||||
self.api = api ?? NetworkServices.shared.messageCenterAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
@ -24,9 +24,9 @@ final class MessageDetailViewController: BaseViewController {
|
||||
private let deleteButton = UIButton(type: .system)
|
||||
|
||||
/// 初始化消息详情页。
|
||||
init(message: MessageItem, api: any MessageCenterServing = NetworkServices.shared.messageCenterAPI) {
|
||||
init(message: MessageItem, api: (any MessageCenterServing)? = nil) {
|
||||
viewModel = MessageDetailViewModel(message: message)
|
||||
self.api = api
|
||||
self.api = api ?? NetworkServices.shared.messageCenterAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
@ -14,9 +14,9 @@ final class DepositOrderDetailViewController: BaseViewController, UITableViewDat
|
||||
private var detail: StoreOrderDetailData?
|
||||
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
||||
|
||||
init(orderNumber: String, orderAPI: OrderAPI = NetworkServices.shared.orderAPI) {
|
||||
init(orderNumber: String, orderAPI: OrderAPI? = nil) {
|
||||
self.orderNumber = orderNumber
|
||||
self.orderAPI = orderAPI
|
||||
self.orderAPI = orderAPI ?? NetworkServices.shared.orderAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
@ -14,9 +14,9 @@ final class HistoricalShootingInfoViewController: BaseViewController, UITableVie
|
||||
private var response: MultiTravelShootHistoryResponse?
|
||||
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
||||
|
||||
init(orderNumber: String, orderAPI: OrderAPI = NetworkServices.shared.orderAPI) {
|
||||
init(orderNumber: String, orderAPI: OrderAPI? = nil) {
|
||||
self.orderNumber = orderNumber
|
||||
self.orderAPI = orderAPI
|
||||
self.orderAPI = orderAPI ?? NetworkServices.shared.orderAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
@ -53,10 +53,10 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
|
||||
|
||||
init(
|
||||
viewModel: ProfileSpaceSettingsViewModel = ProfileSpaceSettingsViewModel(),
|
||||
profileAPI: ProfileAPI = NetworkServices.shared.profileAPI
|
||||
profileAPI: ProfileAPI? = nil
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.profileAPI = profileAPI
|
||||
self.profileAPI = profileAPI ?? NetworkServices.shared.profileAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
@ -26,10 +26,10 @@ final class SampleDetailViewController: SampleBaseViewController {
|
||||
/// 初始化样片详情页。
|
||||
init(
|
||||
sampleId: Int,
|
||||
api: any SampleManagementServing = NetworkServices.shared.sampleManagementAPI
|
||||
api: (any SampleManagementServing)? = nil
|
||||
) {
|
||||
viewModel = SampleDetailViewModel(sampleId: sampleId)
|
||||
self.api = api
|
||||
self.api = api ?? NetworkServices.shared.sampleManagementAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
@ -32,10 +32,10 @@ final class SampleListViewController: SampleBaseViewController {
|
||||
/// 初始化样片列表页。
|
||||
init(
|
||||
viewModel: SampleListViewModel = SampleListViewModel(),
|
||||
api: any SampleManagementServing = NetworkServices.shared.sampleManagementAPI
|
||||
api: (any SampleManagementServing)? = nil
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.api = api
|
||||
self.api = api ?? NetworkServices.shared.sampleManagementAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
@ -51,12 +51,12 @@ final class UploadSampleViewController: SampleBaseViewController {
|
||||
/// 初始化样片上传页。
|
||||
init(
|
||||
viewModel: UploadSampleViewModel = UploadSampleViewModel(),
|
||||
api: any SampleManagementServing = NetworkServices.shared.sampleManagementAPI,
|
||||
uploader: any SampleOSSUploading = NetworkServices.shared.ossUploadService
|
||||
api: (any SampleManagementServing)? = nil,
|
||||
uploader: (any SampleOSSUploading)? = nil
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.api = api
|
||||
self.uploader = uploader
|
||||
self.api = api ?? NetworkServices.shared.sampleManagementAPI
|
||||
self.uploader = uploader ?? NetworkServices.shared.ossUploadService
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
@ -41,14 +41,14 @@ final class ScenicApplicationViewController: BaseViewController {
|
||||
|
||||
init(
|
||||
viewModel: ScenicApplicationViewModel = ScenicApplicationViewModel(),
|
||||
homeAPI: HomeAPI = NetworkServices.shared.homeAPI,
|
||||
profileAPI: ProfileAPI = NetworkServices.shared.profileAPI,
|
||||
ossUploadService: OSSUploadService = NetworkServices.shared.ossUploadService
|
||||
homeAPI: HomeAPI? = nil,
|
||||
profileAPI: ProfileAPI? = nil,
|
||||
ossUploadService: OSSUploadService? = nil
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.homeAPI = homeAPI
|
||||
self.profileAPI = profileAPI
|
||||
self.ossUploadService = ossUploadService
|
||||
self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI
|
||||
self.profileAPI = profileAPI ?? NetworkServices.shared.profileAPI
|
||||
self.ossUploadService = ossUploadService ?? NetworkServices.shared.ossUploadService
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
@ -6,12 +6,15 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 排队设置配置日志页。
|
||||
/// 排队配置日志页,按 Android 时间轴样式展示变更历史。
|
||||
final class ScenicQueueSettingChangeLogViewController: BaseViewController {
|
||||
private let viewModel = ScenicQueueSettingChangeLogViewModel()
|
||||
private let api: ScenicQueueAPIProtocol
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let emptyLabel = UILabel()
|
||||
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
|
||||
private let footerLabel = UILabel()
|
||||
private var dataSource: UITableViewDiffableDataSource<Int, ScenicQueueSettingChangeLogItem>!
|
||||
|
||||
@MainActor
|
||||
init(api: ScenicQueueAPIProtocol? = nil) {
|
||||
@ -20,133 +23,142 @@ final class ScenicQueueSettingChangeLogViewController: BaseViewController {
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
Task { await viewModel.refresh(api: api) }
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "配置日志"
|
||||
}
|
||||
override func setupNavigationBar() { title = "配置日志" }
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = ScenicQueueTokens.pageBackground
|
||||
tableView.backgroundColor = .clear
|
||||
view.backgroundColor = .white
|
||||
tableView.backgroundColor = .white
|
||||
tableView.separatorStyle = .none
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 120
|
||||
tableView.estimatedRowHeight = 132
|
||||
tableView.delegate = self
|
||||
tableView.dataSource = self
|
||||
tableView.register(ScenicQueueLogCell.self, forCellReuseIdentifier: ScenicQueueLogCell.reuseIdentifier)
|
||||
tableView.register(ScenicQueueLogTimelineCell.self, forCellReuseIdentifier: ScenicQueueLogTimelineCell.reuseIdentifier)
|
||||
tableView.refreshControl = UIRefreshControl()
|
||||
tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||
dataSource = UITableViewDiffableDataSource<Int, ScenicQueueSettingChangeLogItem>(tableView: tableView) { [weak self] tableView, indexPath, item in
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueLogTimelineCell.reuseIdentifier, for: indexPath) as! ScenicQueueLogTimelineCell
|
||||
cell.configure(item: item, showDot: self?.shouldShowDot(at: indexPath.row) ?? false)
|
||||
return cell
|
||||
}
|
||||
emptyLabel.text = "暂无配置变更记录"
|
||||
emptyLabel.textColor = AppColor.textTertiary
|
||||
emptyLabel.textColor = AppColor.textSecondary
|
||||
emptyLabel.font = ScenicQueueTokens.emptyStateFont
|
||||
emptyLabel.textAlignment = .center
|
||||
loadingIndicator.color = ScenicQueueTokens.bannerBlue
|
||||
footerLabel.textAlignment = .center
|
||||
footerLabel.textColor = AppColor.textSecondary
|
||||
footerLabel.font = .systemFont(ofSize: 14)
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(emptyLabel)
|
||||
view.addSubview(loadingIndicator)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||||
}
|
||||
emptyLabel.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
tableView.snp.makeConstraints { $0.edges.equalTo(view.safeAreaLayoutGuide) }
|
||||
emptyLabel.snp.makeConstraints { $0.center.equalToSuperview() }
|
||||
loadingIndicator.snp.makeConstraints { $0.center.equalToSuperview() }
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyState() }
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } }
|
||||
viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } }
|
||||
}
|
||||
|
||||
private func applyState() {
|
||||
tableView.refreshControl?.endRefreshing()
|
||||
tableView.reloadData()
|
||||
emptyLabel.isHidden = !viewModel.items.isEmpty
|
||||
viewModel.initialLoading && viewModel.items.isEmpty ? loadingIndicator.startAnimating() : loadingIndicator.stopAnimating()
|
||||
emptyLabel.isHidden = viewModel.initialLoading || viewModel.isRefreshing || !viewModel.items.isEmpty
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Int, ScenicQueueSettingChangeLogItem>()
|
||||
snapshot.appendSections([0])
|
||||
snapshot.appendItems(viewModel.items)
|
||||
dataSource.apply(snapshot, animatingDifferences: true)
|
||||
if viewModel.items.isEmpty {
|
||||
tableView.tableFooterView = UIView()
|
||||
} else {
|
||||
footerLabel.text = viewModel.isLoadingMore ? "加载中…" : (viewModel.canLoadMore ? "" : "没有更多")
|
||||
footerLabel.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52)
|
||||
tableView.tableFooterView = footerLabel
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func refreshPulled() {
|
||||
Task { await viewModel.refresh(api: api) }
|
||||
private func shouldShowDot(at index: Int) -> Bool {
|
||||
let estimatedRowHeight: CGFloat = 132
|
||||
let itemsPerThird = max(1, Int(((view.bounds.height / 3) / estimatedRowHeight).rounded()))
|
||||
let stride = max(3, min(12, itemsPerThird))
|
||||
return index % stride == 0
|
||||
}
|
||||
|
||||
@objc private func refreshPulled() { Task { await viewModel.refresh(api: api) } }
|
||||
}
|
||||
|
||||
extension ScenicQueueSettingChangeLogViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
viewModel.items.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueLogCell.reuseIdentifier, for: indexPath) as! ScenicQueueLogCell
|
||||
cell.configure(item: viewModel.items[indexPath.row])
|
||||
return cell
|
||||
}
|
||||
|
||||
extension ScenicQueueSettingChangeLogViewController: UITableViewDelegate {
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 120 else { return }
|
||||
guard !viewModel.items.isEmpty,
|
||||
scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 180 else { return }
|
||||
Task { await viewModel.loadMore(api: api) }
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置日志 cell。
|
||||
final class ScenicQueueLogCell: UITableViewCell {
|
||||
static let reuseIdentifier = "ScenicQueueLogCell"
|
||||
|
||||
/// 配置日志的时间轴卡片单元格。
|
||||
final class ScenicQueueLogTimelineCell: UITableViewCell {
|
||||
static let reuseIdentifier = "ScenicQueueLogTimelineCell"
|
||||
private let rail = UIView()
|
||||
private let dot = UIView()
|
||||
private let card = UIView()
|
||||
private let titleLabel = UILabel()
|
||||
private let detailLabel = UILabel()
|
||||
private let timeLabel = UILabel()
|
||||
private let metaLabel = UILabel()
|
||||
private let summaryLabel = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
setupUI()
|
||||
selectionStyle = .none
|
||||
backgroundColor = .white
|
||||
rail.backgroundColor = ScenicQueueTokens.timelineLine
|
||||
dot.backgroundColor = ScenicQueueTokens.timelineDot
|
||||
dot.layer.cornerRadius = 4
|
||||
card.backgroundColor = ScenicQueueTokens.logCardBackground
|
||||
card.layer.cornerRadius = 12
|
||||
metaLabel.font = .systemFont(ofSize: 12)
|
||||
metaLabel.textColor = AppColor.textSecondary
|
||||
metaLabel.numberOfLines = 3
|
||||
summaryLabel.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||
summaryLabel.textColor = AppColor.textPrimary
|
||||
summaryLabel.numberOfLines = 0
|
||||
let stack = UIStackView(arrangedSubviews: [metaLabel, summaryLabel])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
contentView.addSubview(rail)
|
||||
contentView.addSubview(dot)
|
||||
contentView.addSubview(card)
|
||||
card.addSubview(stack)
|
||||
rail.snp.makeConstraints { make in
|
||||
make.centerX.equalTo(contentView.snp.leading).offset(UIScreen.main.bounds.width * 0.10)
|
||||
make.top.bottom.equalToSuperview()
|
||||
make.width.equalTo(1.5)
|
||||
}
|
||||
dot.snp.makeConstraints { make in make.centerX.equalTo(rail); make.top.equalToSuperview().offset(18); make.size.equalTo(8) }
|
||||
card.snp.makeConstraints { make in
|
||||
make.leading.equalTo(rail.snp.trailing).offset(20)
|
||||
make.trailing.equalToSuperview().offset(-16)
|
||||
make.top.equalToSuperview().offset(6)
|
||||
make.bottom.equalToSuperview().offset(-6)
|
||||
}
|
||||
stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(15) }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
func configure(item: ScenicQueueSettingChangeLogItem) {
|
||||
titleLabel.text = item.operatorName.isEmpty ? "配置变更" : item.operatorName
|
||||
detailLabel.text = item.displayText.isEmpty ? item.summary : item.displayText
|
||||
timeLabel.text = item.createdAt
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = ScenicQueueTokens.radiusCard
|
||||
card.layer.borderWidth = 1
|
||||
card.layer.borderColor = ScenicQueueTokens.cardOutline.cgColor
|
||||
contentView.addSubview(card)
|
||||
titleLabel.font = ScenicQueueTokens.settingsSectionTitleFont
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
detailLabel.font = ScenicQueueTokens.settingsRowTitleFont
|
||||
detailLabel.textColor = AppColor.textSecondary
|
||||
detailLabel.numberOfLines = 0
|
||||
timeLabel.font = ScenicQueueTokens.settingsHintFont
|
||||
timeLabel.textColor = AppColor.textTertiary
|
||||
let stack = UIStackView(arrangedSubviews: [titleLabel, detailLabel, timeLabel])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
card.addSubview(stack)
|
||||
card.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 16, bottom: 6, right: 16))
|
||||
}
|
||||
stack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(14)
|
||||
}
|
||||
/// 绑定日志内容与时间轴圆点显隐。
|
||||
func configure(item: ScenicQueueSettingChangeLogItem, showDot: Bool) {
|
||||
metaLabel.text = item.metadataLine
|
||||
summaryLabel.text = item.cardSummary
|
||||
dot.isHidden = !showDot
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -15,12 +15,13 @@ final class ScenicQueueViewController: BaseViewController {
|
||||
private let punchSpotLabel = UILabel()
|
||||
private let statsBanner = ScenicQueueStatsBannerView()
|
||||
private let contentContainer = UIView()
|
||||
private let segmentedControl = UISegmentedControl(items: ["当前排队", "过号列表"])
|
||||
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
|
||||
|
||||
@ -64,8 +65,8 @@ final class ScenicQueueViewController: BaseViewController {
|
||||
view.backgroundColor = ScenicQueueTokens.pageBackground
|
||||
|
||||
punchSpotRow.backgroundColor = .white
|
||||
let locationIcon = UIImageView(image: UIImage(systemName: "mappin.and.ellipse"))
|
||||
locationIcon.tintColor = ScenicQueueTokens.bannerBlue
|
||||
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
|
||||
@ -75,29 +76,21 @@ final class ScenicQueueViewController: BaseViewController {
|
||||
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)
|
||||
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.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)
|
||||
configureDataSource()
|
||||
|
||||
emptyLabel.textColor = AppColor.textTertiary
|
||||
emptyLabel.font = ScenicQueueTokens.emptyStateFont
|
||||
@ -114,7 +107,7 @@ final class ScenicQueueViewController: BaseViewController {
|
||||
view.addSubview(punchSpotRow)
|
||||
view.addSubview(statsBanner)
|
||||
view.addSubview(contentContainer)
|
||||
contentContainer.addSubview(segmentedControl)
|
||||
contentContainer.addSubview(tabStrip)
|
||||
contentContainer.addSubview(tableView)
|
||||
contentContainer.addSubview(emptyLabel)
|
||||
view.addSubview(bottomBar)
|
||||
@ -149,12 +142,13 @@ final class ScenicQueueViewController: BaseViewController {
|
||||
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)
|
||||
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(segmentedControl.snp.bottom).offset(8)
|
||||
make.top.equalTo(tabStrip.snp.bottom)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
emptyLabel.snp.makeConstraints { make in
|
||||
@ -177,7 +171,7 @@ final class ScenicQueueViewController: BaseViewController {
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
segmentedControl.addTarget(self, action: #selector(tabChanged), for: .valueChanged)
|
||||
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
|
||||
@ -202,7 +196,7 @@ final class ScenicQueueViewController: BaseViewController {
|
||||
punchSpotLabel.text = viewModel.selectedPunchSpotLine
|
||||
statsBanner.configure(summary: viewModel.summary)
|
||||
tableView.refreshControl?.endRefreshing()
|
||||
tableView.reloadData()
|
||||
applySnapshot(animated: true)
|
||||
let isEmpty = selectedTab == 0 ? viewModel.currentQueue.isEmpty : viewModel.skippedQueue.isEmpty
|
||||
emptyLabel.text = selectedTab == 0 ? "暂无排队" : "暂无过号记录"
|
||||
emptyLabel.isHidden = !isEmpty
|
||||
@ -212,6 +206,41 @@ final class ScenicQueueViewController: BaseViewController {
|
||||
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
|
||||
@ -232,42 +261,63 @@ final class ScenicQueueViewController: BaseViewController {
|
||||
|
||||
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)
|
||||
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, 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 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 }
|
||||
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)
|
||||
present(ScenicQueueDialSheetViewController(digits: digits), animated: true)
|
||||
}
|
||||
|
||||
@objc private func settingsTapped() {
|
||||
viewModel.openSettings()
|
||||
}
|
||||
|
||||
@objc private func tabChanged() {
|
||||
selectedTab = segmentedControl.selectedSegmentIndex
|
||||
tableView.reloadData()
|
||||
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()
|
||||
}
|
||||
|
||||
@ -298,30 +348,7 @@ final class ScenicQueueViewController: BaseViewController {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
extension ScenicQueueViewController: UITableViewDelegate {
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 120 else { return }
|
||||
Task {
|
||||
@ -341,7 +368,9 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
|
||||
|
||||
func scenicQueueTicketCellDidTapSkip(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
|
||||
viewModel.requestSkipTicket(recordId: ticket.recordId, queueNo: ticket.queueNo)
|
||||
presentConfirm(title: "确认过号", message: ticket.queueNo) { [weak self] in
|
||||
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) }
|
||||
}
|
||||
@ -350,7 +379,9 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
|
||||
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
|
||||
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) }
|
||||
}
|
||||
@ -361,7 +392,9 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
|
||||
|
||||
func scenicQueueTicketCellDidTapRecall(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
|
||||
viewModel.requestCallConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo, isRecall: true)
|
||||
presentConfirm(title: "重新叫号", message: ticket.queueNo) { [weak self] in
|
||||
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) }
|
||||
}
|
||||
@ -369,7 +402,9 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
|
||||
|
||||
func scenicQueueTicketCellDidTapComplete(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
|
||||
viewModel.requestFinishConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo)
|
||||
presentConfirm(title: "确认完成拍摄", message: ticket.queueNo) { [weak self] in
|
||||
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) }
|
||||
}
|
||||
@ -377,25 +412,16 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -406,7 +432,9 @@ extension ScenicQueueViewController: ScenicQueueSkippedCellDelegate {
|
||||
|
||||
func scenicQueueSkippedCellDidTapRequeue(_ cell: ScenicQueueSkippedCell, ticket: ScenicQueueSkippedTicket) {
|
||||
viewModel.requestRequeueConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo)
|
||||
presentConfirm(title: "确认重新排队", message: ticket.queueNo) { [weak self] in
|
||||
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) }
|
||||
}
|
||||
|
||||
396
suixinkan/UI/ScenicQueue/Views/ScenicQueueOverlayViews.swift
Normal file
396
suixinkan/UI/ScenicQueue/Views/ScenicQueueOverlayViews.swift
Normal file
@ -0,0 +1,396 @@
|
||||
//
|
||||
// ScenicQueueOverlayViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// Android 排队模块样式的等宽标签栏。
|
||||
final class ScenicQueueTabStripView: UIView {
|
||||
private let stackView = UIStackView()
|
||||
private let indicator = UIView()
|
||||
private let divider = UIView()
|
||||
private var buttons: [UIButton] = []
|
||||
|
||||
/// 用户选择标签时回传索引。
|
||||
var onSelect: ((Int) -> Void)?
|
||||
|
||||
/// 当前选中索引。
|
||||
private(set) var selectedIndex = 0
|
||||
|
||||
init(titles: [String]) {
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = .white
|
||||
stackView.axis = .horizontal
|
||||
stackView.distribution = .fillEqually
|
||||
titles.enumerated().forEach { index, title in
|
||||
let button = UIButton(type: .system)
|
||||
button.tag = index
|
||||
button.setTitle(title, for: .normal)
|
||||
button.titleLabel?.font = ScenicQueueTokens.tabFont
|
||||
button.addTarget(self, action: #selector(buttonTapped(_:)), for: .touchUpInside)
|
||||
buttons.append(button)
|
||||
stackView.addArrangedSubview(button)
|
||||
}
|
||||
indicator.backgroundColor = ScenicQueueTokens.bannerBlue
|
||||
divider.backgroundColor = ScenicQueueTokens.cardOutline
|
||||
addSubview(stackView)
|
||||
addSubview(divider)
|
||||
addSubview(indicator)
|
||||
stackView.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||||
divider.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
make.height.equalTo(1 / UIScreen.main.scale)
|
||||
}
|
||||
setSelectedIndex(0, animated: false)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
updateIndicatorFrame()
|
||||
}
|
||||
|
||||
/// 更新选中标签并移动 3pt 蓝色指示条。
|
||||
func setSelectedIndex(_ index: Int, animated: Bool) {
|
||||
guard buttons.indices.contains(index) else { return }
|
||||
selectedIndex = index
|
||||
buttons.enumerated().forEach { item in
|
||||
item.element.setTitleColor(item.offset == index ? ScenicQueueTokens.bannerBlue : AppColor.textSecondary, for: .normal)
|
||||
item.element.titleLabel?.font = item.offset == index ? ScenicQueueTokens.tabSelectedFont : ScenicQueueTokens.tabFont
|
||||
}
|
||||
let changes = { self.updateIndicatorFrame() }
|
||||
animated ? UIView.animate(withDuration: 0.22, animations: changes) : changes()
|
||||
}
|
||||
|
||||
private func updateIndicatorFrame() {
|
||||
guard !buttons.isEmpty, bounds.width > 0 else { return }
|
||||
let width = bounds.width / CGFloat(buttons.count)
|
||||
indicator.frame = CGRect(x: width * CGFloat(selectedIndex), y: bounds.height - 3, width: width, height: 3)
|
||||
}
|
||||
|
||||
@objc private func buttonTapped(_ sender: UIButton) {
|
||||
setSelectedIndex(sender.tag, animated: true)
|
||||
onSelect?(sender.tag)
|
||||
}
|
||||
}
|
||||
|
||||
/// 排队模块确认弹层的固定展示配置。
|
||||
struct ScenicQueueDialogConfiguration {
|
||||
let title: String
|
||||
let message: String?
|
||||
let emphasizedText: String?
|
||||
let dangerText: String?
|
||||
let cancelTitle: String
|
||||
let confirmTitle: String
|
||||
let buttonRadius: CGFloat
|
||||
let dismissOnBackdrop: Bool
|
||||
}
|
||||
|
||||
/// Android 排队模块样式的居中确认弹层。
|
||||
final class ScenicQueueConfirmationDialogViewController: UIViewController, UIGestureRecognizerDelegate {
|
||||
private let configuration: ScenicQueueDialogConfiguration
|
||||
private let onCancel: () -> Void
|
||||
private let onConfirm: () -> Void
|
||||
|
||||
init(configuration: ScenicQueueDialogConfiguration, onCancel: @escaping () -> Void, onConfirm: @escaping () -> Void) {
|
||||
self.configuration = configuration
|
||||
self.onCancel = onCancel
|
||||
self.onConfirm = onConfirm
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .overFullScreen
|
||||
modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor.black.withAlphaComponent(0.42)
|
||||
if configuration.dismissOnBackdrop {
|
||||
let backdropTap = UITapGestureRecognizer(target: self, action: #selector(cancelTapped))
|
||||
backdropTap.delegate = self
|
||||
view.addGestureRecognizer(backdropTap)
|
||||
}
|
||||
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = ScenicQueueTokens.radiusDialog
|
||||
card.layer.shadowColor = UIColor.black.cgColor
|
||||
card.layer.shadowOpacity = 0.18
|
||||
card.layer.shadowRadius = 8
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = configuration.title
|
||||
titleLabel.font = ScenicQueueTokens.dialogTitleFont
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
let contentStack = UIStackView(arrangedSubviews: [titleLabel])
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 10
|
||||
if let message = configuration.message {
|
||||
let label = makeLabel(message, font: .systemFont(ofSize: 15), color: AppColor.textSecondary)
|
||||
contentStack.addArrangedSubview(label)
|
||||
}
|
||||
if let danger = configuration.dangerText {
|
||||
let label = makeLabel(danger, font: .systemFont(ofSize: 13), color: ScenicQueueTokens.dialogDangerLine)
|
||||
contentStack.addArrangedSubview(label)
|
||||
}
|
||||
if let emphasized = configuration.emphasizedText {
|
||||
let label = makeLabel(emphasized, font: ScenicQueueTokens.dialogTicketFont, color: AppColor.textPrimary)
|
||||
contentStack.addArrangedSubview(label)
|
||||
contentStack.setCustomSpacing(20, after: label)
|
||||
}
|
||||
|
||||
let cancel = makeButton(configuration.cancelTitle, background: ScenicQueueTokens.dialogCancelBackground, foreground: ScenicQueueTokens.bannerBlue)
|
||||
cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
let confirm = makeButton(configuration.confirmTitle, background: ScenicQueueTokens.bannerBlue, foreground: .white)
|
||||
confirm.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||
cancel.layer.cornerRadius = configuration.buttonRadius
|
||||
confirm.layer.cornerRadius = configuration.buttonRadius
|
||||
let buttons = UIStackView(arrangedSubviews: [cancel, confirm])
|
||||
buttons.axis = .horizontal
|
||||
buttons.distribution = .fillEqually
|
||||
buttons.spacing = 12
|
||||
contentStack.addArrangedSubview(buttons)
|
||||
|
||||
view.addSubview(card)
|
||||
card.addSubview(contentStack)
|
||||
card.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview().inset(32)
|
||||
}
|
||||
contentStack.snp.makeConstraints { $0.edges.equalToSuperview().inset(22) }
|
||||
buttons.snp.makeConstraints { $0.height.equalTo(44) }
|
||||
}
|
||||
|
||||
private func makeLabel(_ text: String, font: UIFont, color: UIColor) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = text
|
||||
label.font = font
|
||||
label.textColor = color
|
||||
label.textAlignment = .center
|
||||
label.numberOfLines = 0
|
||||
return label
|
||||
}
|
||||
|
||||
private func makeButton(_ title: String, background: UIColor, foreground: UIColor) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setTitleColor(foreground, for: .normal)
|
||||
button.titleLabel?.font = ScenicQueueTokens.dialogButtonFont
|
||||
button.backgroundColor = background
|
||||
button.clipsToBounds = true
|
||||
return button
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() {
|
||||
dismiss(animated: true, completion: onCancel)
|
||||
}
|
||||
|
||||
@objc private func confirmTapped() {
|
||||
dismiss(animated: true, completion: onConfirm)
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||
touch.view === view
|
||||
}
|
||||
}
|
||||
|
||||
/// Android 排队模块样式的拨号底部弹层。
|
||||
final class ScenicQueueDialSheetViewController: UIViewController, UIGestureRecognizerDelegate {
|
||||
private let digits: String
|
||||
|
||||
init(digits: String) {
|
||||
self.digits = digits
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .overFullScreen
|
||||
modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor.black.withAlphaComponent(0.36)
|
||||
let backdropTap = UITapGestureRecognizer(target: self, action: #selector(cancelTapped))
|
||||
backdropTap.delegate = self
|
||||
view.addGestureRecognizer(backdropTap)
|
||||
let panel = UIView()
|
||||
panel.backgroundColor = .white
|
||||
panel.layer.cornerRadius = 18
|
||||
panel.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||
let title = UILabel()
|
||||
title.text = "拨打电话"
|
||||
title.font = ScenicQueueTokens.dialogTitleFont
|
||||
title.textAlignment = .center
|
||||
let number = UILabel()
|
||||
number.text = digits
|
||||
number.font = .systemFont(ofSize: 18, weight: .medium)
|
||||
number.textColor = AppColor.textPrimary
|
||||
number.textAlignment = .center
|
||||
let cancel = pillButton("取消", background: ScenicQueueTokens.dialogCancelBackground, foreground: ScenicQueueTokens.bannerBlue)
|
||||
cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
let dial = pillButton("拨打", background: ScenicQueueTokens.bannerBlue, foreground: .white)
|
||||
dial.addTarget(self, action: #selector(dialTapped), for: .touchUpInside)
|
||||
let buttons = UIStackView(arrangedSubviews: [cancel, dial])
|
||||
buttons.axis = .horizontal
|
||||
buttons.distribution = .fillEqually
|
||||
buttons.spacing = 12
|
||||
view.addSubview(panel)
|
||||
[title, number, buttons].forEach(panel.addSubview)
|
||||
panel.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() }
|
||||
title.snp.makeConstraints { make in make.top.equalToSuperview().offset(18); make.centerX.equalToSuperview() }
|
||||
number.snp.makeConstraints { make in make.top.equalTo(title.snp.bottom).offset(18); make.leading.trailing.equalToSuperview().inset(16) }
|
||||
buttons.snp.makeConstraints { make in
|
||||
make.top.equalTo(number.snp.bottom).offset(18)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.height.equalTo(44)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||
}
|
||||
}
|
||||
|
||||
private func pillButton(_ title: String, background: UIColor, foreground: UIColor) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setTitleColor(foreground, for: .normal)
|
||||
button.titleLabel?.font = ScenicQueueTokens.dialogButtonFont
|
||||
button.backgroundColor = background
|
||||
button.layer.cornerRadius = 22
|
||||
return button
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() { dismiss(animated: true) }
|
||||
|
||||
@objc private func dialTapped() {
|
||||
dismiss(animated: true) {
|
||||
guard let url = URL(string: "tel:\(self.digits)") else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||
touch.view === view
|
||||
}
|
||||
}
|
||||
|
||||
/// Android 用户标记弹层,支持打野标记和限制排队天数步进。
|
||||
final class ScenicQueueMarkDialogViewController: UIViewController {
|
||||
private let pending: ScenicQueuePendingMark
|
||||
private let onCancel: () -> Void
|
||||
private let onConfirm: (Bool, Int) -> Void
|
||||
private let markSwitch = UISwitch()
|
||||
private let daysLabel = UILabel()
|
||||
private var days = 0 { didSet { daysLabel.text = "\(days)" } }
|
||||
|
||||
init(pending: ScenicQueuePendingMark, onCancel: @escaping () -> Void, onConfirm: @escaping (Bool, Int) -> Void) {
|
||||
self.pending = pending
|
||||
self.onCancel = onCancel
|
||||
self.onConfirm = onConfirm
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .overFullScreen
|
||||
modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor.black.withAlphaComponent(0.42)
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = ScenicQueueTokens.markDialogRadius
|
||||
let title = UILabel()
|
||||
title.text = "标记用户"
|
||||
title.font = ScenicQueueTokens.dialogTitleFont
|
||||
title.textAlignment = .center
|
||||
let queue = UILabel()
|
||||
queue.text = pending.queueNo
|
||||
queue.font = .systemFont(ofSize: 24, weight: .bold)
|
||||
queue.textColor = ScenicQueueTokens.bannerBlue
|
||||
let phone = UILabel()
|
||||
phone.text = pending.phoneMasked
|
||||
phone.font = ScenicQueueTokens.phoneMaskedFont
|
||||
phone.textColor = AppColor.textSecondary
|
||||
let identityTitle = UILabel()
|
||||
identityTitle.text = "标记为打野摄影师"
|
||||
identityTitle.font = ScenicQueueTokens.settingsRowTitleFont
|
||||
markSwitch.isOn = pending.markAsFreelancePhotog == 1
|
||||
markSwitch.onTintColor = ScenicQueueTokens.bannerBlue
|
||||
let identityRow = UIStackView(arrangedSubviews: [identityTitle, UIView(), markSwitch])
|
||||
identityRow.axis = .horizontal
|
||||
identityRow.alignment = .center
|
||||
let hint = UILabel()
|
||||
hint.text = "标记后,该用户扫码时将受排队限制。"
|
||||
hint.font = ScenicQueueTokens.settingsHintFont
|
||||
hint.textColor = AppColor.textTertiary
|
||||
hint.numberOfLines = 0
|
||||
let daysTitle = UILabel()
|
||||
daysTitle.text = "限制排队天数"
|
||||
daysTitle.font = ScenicQueueTokens.settingsRowTitleFont
|
||||
let minus = stepButton("−", action: #selector(minusTapped))
|
||||
let plus = stepButton("+", action: #selector(plusTapped))
|
||||
daysLabel.text = "0"
|
||||
daysLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
daysLabel.textAlignment = .center
|
||||
let stepper = UIStackView(arrangedSubviews: [minus, daysLabel, plus])
|
||||
stepper.axis = .horizontal
|
||||
stepper.alignment = .center
|
||||
stepper.distribution = .fillEqually
|
||||
stepper.backgroundColor = ScenicQueueTokens.markDialogStepperBackground
|
||||
stepper.layer.cornerRadius = 10
|
||||
let cancel = actionButton("取消", background: ScenicQueueTokens.markDialogCancelBackground, foreground: ScenicQueueTokens.bannerBlue)
|
||||
cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
let confirm = actionButton("完成", background: ScenicQueueTokens.bannerBlue, foreground: .white)
|
||||
confirm.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||
let buttons = UIStackView(arrangedSubviews: [cancel, confirm])
|
||||
buttons.axis = .horizontal
|
||||
buttons.distribution = .fillEqually
|
||||
buttons.spacing = 12
|
||||
let stack = UIStackView(arrangedSubviews: [title, queue, phone, identityRow, hint, daysTitle, stepper, buttons])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 12
|
||||
view.addSubview(card)
|
||||
card.addSubview(stack)
|
||||
card.snp.makeConstraints { make in make.center.equalToSuperview(); make.width.equalToSuperview().multipliedBy(0.88) }
|
||||
stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(22) }
|
||||
stepper.snp.makeConstraints { $0.height.equalTo(44) }
|
||||
buttons.snp.makeConstraints { $0.height.equalTo(44) }
|
||||
}
|
||||
|
||||
private func stepButton(_ title: String, action: Selector) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setTitleColor(ScenicQueueTokens.bannerBlue, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 22, weight: .medium)
|
||||
button.addTarget(self, action: action, for: .touchUpInside)
|
||||
return button
|
||||
}
|
||||
|
||||
private func actionButton(_ title: String, background: UIColor, foreground: UIColor) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setTitleColor(foreground, for: .normal)
|
||||
button.titleLabel?.font = ScenicQueueTokens.dialogButtonFont
|
||||
button.backgroundColor = background
|
||||
button.layer.cornerRadius = ScenicQueueTokens.markDialogButtonRadius
|
||||
return button
|
||||
}
|
||||
|
||||
@objc private func minusTapped() { days = max(0, days - 1) }
|
||||
@objc private func plusTapped() { days = min(999, days + 1) }
|
||||
@objc private func cancelTapped() { dismiss(animated: true, completion: onCancel) }
|
||||
@objc private func confirmTapped() {
|
||||
let marked = markSwitch.isOn
|
||||
dismiss(animated: true) { self.onConfirm(marked, marked ? self.days : 0) }
|
||||
}
|
||||
}
|
||||
@ -261,33 +261,38 @@ final class ScenicQueueTicketCell: UITableViewCell {
|
||||
|
||||
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)
|
||||
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])
|
||||
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
|
||||
skip.snp.makeConstraints { make in
|
||||
make.height.equalTo(46)
|
||||
make.width.equalTo(primary).multipliedBy(0.56)
|
||||
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)
|
||||
if ticket.status == .called, shootingQueueNo != ticket.queueNo {
|
||||
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 ticket.showCompleteAction {
|
||||
if state.completeVisible {
|
||||
completeButton.addTarget(self, action: #selector(completeTapped), for: .touchUpInside)
|
||||
completeButton.snp.makeConstraints { make in make.height.equalTo(40) }
|
||||
actionsStack.addArrangedSubview(completeButton)
|
||||
@ -329,17 +334,27 @@ final class ScenicQueueTicketCell: UITableViewCell {
|
||||
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)
|
||||
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()
|
||||
}
|
||||
right.snp.makeConstraints { make in
|
||||
valueContainer.snp.makeConstraints { make in
|
||||
make.trailing.centerY.equalToSuperview()
|
||||
make.leading.greaterThanOrEqualTo(left.snp.trailing).offset(12)
|
||||
}
|
||||
@ -490,17 +505,27 @@ final class ScenicQueueSkippedCell: UITableViewCell {
|
||||
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)
|
||||
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()
|
||||
}
|
||||
right.snp.makeConstraints { make in
|
||||
valueContainer.snp.makeConstraints { make in
|
||||
make.trailing.centerY.equalToSuperview()
|
||||
make.leading.greaterThanOrEqualTo(left.snp.trailing).offset(12)
|
||||
}
|
||||
|
||||
@ -19,10 +19,10 @@ final class SettingViewController: BaseViewController {
|
||||
|
||||
init(
|
||||
viewModel: SettingViewModel = SettingViewModel(),
|
||||
settingAPI: SettingAPI = NetworkServices.shared.settingAPI
|
||||
settingAPI: SettingAPI? = nil
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.settingAPI = settingAPI
|
||||
self.settingAPI = settingAPI ?? NetworkServices.shared.settingAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
@ -28,9 +28,9 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
private let uploadButton = UIButton(type: .system)
|
||||
private var isDeleteSelectedButtonVisible = false
|
||||
|
||||
init(albumId: Int, api: any TravelAlbumServing = NetworkServices.shared.travelAlbumAPI) {
|
||||
init(albumId: Int, api: (any TravelAlbumServing)? = nil) {
|
||||
viewModel = TravelAlbumDetailViewModel(albumId: albumId)
|
||||
self.api = api
|
||||
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
@ -18,8 +18,8 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
||||
private let emptyView = TravelAlbumEmptyView()
|
||||
private var dataSource: UITableViewDiffableDataSource<Int, TravelAlbum>!
|
||||
|
||||
init(api: any TravelAlbumServing = NetworkServices.shared.travelAlbumAPI) {
|
||||
self.api = api
|
||||
init(api: (any TravelAlbumServing)? = nil) {
|
||||
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
@ -36,10 +36,10 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg
|
||||
/// 初始化积分兑现页面。
|
||||
init(
|
||||
viewModel: PointsRedemptionViewModel = PointsRedemptionViewModel(),
|
||||
walletAPI: any WalletPageServing = NetworkServices.shared.walletAPI
|
||||
walletAPI: (any WalletPageServing)? = nil
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.walletAPI = walletAPI
|
||||
self.walletAPI = walletAPI ?? NetworkServices.shared.walletAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
@ -41,12 +41,12 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
|
||||
/// 初始化钱包页面。
|
||||
init(
|
||||
viewModel: WalletViewModel = WalletViewModel(),
|
||||
walletAPI: any WalletPageServing = NetworkServices.shared.walletAPI,
|
||||
profileAPI: any WalletProfileServing = NetworkServices.shared.profileAPI
|
||||
walletAPI: (any WalletPageServing)? = nil,
|
||||
profileAPI: (any WalletProfileServing)? = nil
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.walletAPI = walletAPI
|
||||
self.profileAPI = profileAPI
|
||||
self.walletAPI = walletAPI ?? NetworkServices.shared.walletAPI
|
||||
self.profileAPI = profileAPI ?? NetworkServices.shared.profileAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
@ -34,10 +34,10 @@ final class WithdrawViewController: BaseViewController {
|
||||
/// 初始化提现申请页面。
|
||||
init(
|
||||
viewModel: WithdrawViewModel = WithdrawViewModel(),
|
||||
walletAPI: any WalletPageServing = NetworkServices.shared.walletAPI
|
||||
walletAPI: (any WalletPageServing)? = nil
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.walletAPI = walletAPI
|
||||
self.walletAPI = walletAPI ?? NetworkServices.shared.walletAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
@ -15,10 +15,10 @@ final class WildPhotographerReportHomeViewController: BaseViewController {
|
||||
|
||||
init(
|
||||
viewModel: WildPhotographerReportHomeViewModel = WildPhotographerReportHomeViewModel(),
|
||||
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI
|
||||
api: (any WildPhotographerReportServing)? = nil
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.api = api
|
||||
self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
@ -18,10 +18,10 @@ final class WildPhotographerReportListViewController: BaseViewController {
|
||||
|
||||
init(
|
||||
homeViewModel: WildPhotographerReportHomeViewModel,
|
||||
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI
|
||||
api: (any WildPhotographerReportServing)? = nil
|
||||
) {
|
||||
self.viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
|
||||
self.api = api
|
||||
self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@ -560,11 +560,11 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
|
||||
init(
|
||||
record: WildReportRecord,
|
||||
homeViewModel: WildPhotographerReportHomeViewModel,
|
||||
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI
|
||||
api: (any WildPhotographerReportServing)? = nil
|
||||
) {
|
||||
self.viewModel = WildPhotographerReportDetailViewModel(record: record, homeViewModel: homeViewModel)
|
||||
self.homeViewModel = homeViewModel
|
||||
self.api = api
|
||||
self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
@ -16,6 +16,8 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack = UIStackView()
|
||||
private let descriptionView = UITextView()
|
||||
private let descriptionPlaceholderLabel = UILabel()
|
||||
private let descriptionCountLabel = UILabel()
|
||||
private let contactField = UITextField()
|
||||
private let submitButton = UIButton(type: .system)
|
||||
private var pickerTarget: WildReportPickerTarget = .image
|
||||
@ -25,12 +27,12 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
|
||||
init(
|
||||
homeViewModel: WildPhotographerReportHomeViewModel,
|
||||
locationProvider: any LocationProviding = LocationProvider.shared,
|
||||
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI,
|
||||
uploader: any WildReportAttachmentUploading = NetworkServices.shared.ossUploadService
|
||||
api: (any WildPhotographerReportServing)? = nil,
|
||||
uploader: (any WildReportAttachmentUploading)? = nil
|
||||
) {
|
||||
self.homeViewModel = homeViewModel
|
||||
self.api = api
|
||||
self.uploader = uploader
|
||||
self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
|
||||
self.uploader = uploader ?? NetworkServices.shared.ossUploadService
|
||||
self.viewModel = WildPhotographerReportSubmitViewModel(
|
||||
homeViewModel: homeViewModel,
|
||||
locationProvider: locationProvider
|
||||
@ -64,6 +66,14 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
|
||||
descriptionView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 34, right: 8)
|
||||
descriptionView.delegate = self
|
||||
|
||||
descriptionPlaceholderLabel.text = "请描述摄影师的位置、特征、行为..."
|
||||
descriptionPlaceholderLabel.font = .systemFont(ofSize: 15)
|
||||
descriptionPlaceholderLabel.textColor = UIColor(hex: 0x9CA3AF)
|
||||
|
||||
descriptionCountLabel.font = .systemFont(ofSize: 14)
|
||||
descriptionCountLabel.textColor = UIColor(hex: 0x6B7280)
|
||||
descriptionCountLabel.textAlignment = .right
|
||||
|
||||
contactField.placeholder = "请输入摄影师微信号或手机号(选填)"
|
||||
contactField.font = .app(.body)
|
||||
contactField.textColor = AppColor.textPrimary
|
||||
@ -271,34 +281,28 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
|
||||
private func makeDescriptionCard() -> UIView {
|
||||
let card = sectionCard(index: 3, title: "举报说明")
|
||||
descriptionView.text = viewModel.description
|
||||
updateDescriptionInputState()
|
||||
let inputContainer = UIView()
|
||||
inputContainer.backgroundColor = UIColor(hex: 0xFBFCFE)
|
||||
inputContainer.layer.cornerRadius = 12
|
||||
inputContainer.layer.borderWidth = 1
|
||||
inputContainer.layer.borderColor = UIColor(hex: 0xCDD5E1).cgColor
|
||||
|
||||
let placeholder = UILabel()
|
||||
placeholder.text = "请描述摄影师的位置、特征、行为..."
|
||||
placeholder.font = .systemFont(ofSize: 15)
|
||||
placeholder.textColor = UIColor(hex: 0x9CA3AF)
|
||||
placeholder.isHidden = !viewModel.description.isEmpty
|
||||
|
||||
let count = WildReportUI.label("\(viewModel.description.count)/500", font: .systemFont(ofSize: 14), color: UIColor(hex: 0x6B7280))
|
||||
count.textAlignment = .right
|
||||
|
||||
inputContainer.addSubview(descriptionView)
|
||||
inputContainer.addSubview(placeholder)
|
||||
inputContainer.addSubview(count)
|
||||
inputContainer.addSubview(descriptionPlaceholderLabel)
|
||||
inputContainer.addSubview(descriptionCountLabel)
|
||||
descriptionView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
make.height.equalTo(132)
|
||||
}
|
||||
placeholder.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(18)
|
||||
make.leading.equalToSuperview().offset(14)
|
||||
let textOriginX = descriptionView.textContainerInset.left
|
||||
+ descriptionView.textContainer.lineFragmentPadding
|
||||
descriptionPlaceholderLabel.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(descriptionView.textContainerInset.top)
|
||||
make.leading.equalToSuperview().offset(textOriginX)
|
||||
make.trailing.lessThanOrEqualToSuperview().inset(14)
|
||||
}
|
||||
count.snp.makeConstraints { make in
|
||||
descriptionCountLabel.snp.makeConstraints { make in
|
||||
make.trailing.bottom.equalToSuperview().inset(12)
|
||||
}
|
||||
card.stack.addArrangedSubview(inputContainer)
|
||||
@ -376,6 +380,11 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
|
||||
return card
|
||||
}
|
||||
|
||||
private func updateDescriptionInputState() {
|
||||
descriptionPlaceholderLabel.isHidden = !viewModel.description.isEmpty
|
||||
descriptionCountLabel.text = "\(viewModel.description.count)/500"
|
||||
}
|
||||
|
||||
private func makePaymentCard() -> UIView {
|
||||
let card = sectionCard(index: 6, title: "线下微信、支付宝截图", optional: true)
|
||||
card.stack.addArrangedSubview(makeEvidenceUploadBlock(
|
||||
@ -561,7 +570,12 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
|
||||
)
|
||||
}
|
||||
}
|
||||
@objc private func contactChanged() { viewModel.updateContact(contactField.text ?? "") }
|
||||
@objc private func contactChanged() {
|
||||
viewModel.updateContact(contactField.text ?? "")
|
||||
guard contactField.markedTextRange == nil,
|
||||
contactField.text != viewModel.contact else { return }
|
||||
contactField.text = viewModel.contact
|
||||
}
|
||||
@objc private func dismissKeyboard() { view.endEditing(true) }
|
||||
|
||||
@MainActor
|
||||
@ -654,6 +668,10 @@ extension WildPhotographerReportSubmitViewController: UITextViewDelegate {
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
viewModel.updateDescription(textView.text)
|
||||
if textView.markedTextRange == nil, textView.text != viewModel.description {
|
||||
textView.text = viewModel.description
|
||||
}
|
||||
updateDescriptionInputState()
|
||||
}
|
||||
|
||||
func textViewDidEndEditing(_ textView: UITextView) {
|
||||
|
||||
@ -32,13 +32,13 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
private var isShowingRiskMapLoading = false
|
||||
|
||||
init(
|
||||
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI,
|
||||
api: (any WildPhotographerReportServing)? = nil,
|
||||
locationProvider: any LocationProviding = LocationProvider.shared,
|
||||
scenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId },
|
||||
scenicNameProvider: @escaping () -> String = { AppStore.shared.session.currentScenicName }
|
||||
) {
|
||||
self.viewModel = WildReportRiskMapViewModel()
|
||||
self.api = api
|
||||
self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
|
||||
self.locationProvider = locationProvider
|
||||
self.scenicIdProvider = scenicIdProvider
|
||||
self.scenicNameProvider = scenicNameProvider
|
||||
@ -675,7 +675,7 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
button.tintColor = AppColor.primary
|
||||
button.setTitleColor(AppColor.primary, for: .normal)
|
||||
button.semanticContentAttribute = .forceRightToLeft
|
||||
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: -4)
|
||||
button.setConfigurationImagePlacement(.trailing, padding: 4)
|
||||
if let phone {
|
||||
button.addAction(UIAction { [weak self] _ in
|
||||
self?.callPhone(phone)
|
||||
|
||||
@ -28,13 +28,13 @@ final class WildReportSupplementEvidenceViewController: BaseViewController {
|
||||
init(
|
||||
record: WildReportRecord,
|
||||
homeViewModel: WildPhotographerReportHomeViewModel,
|
||||
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI,
|
||||
uploader: any WildReportAttachmentUploading = NetworkServices.shared.ossUploadService,
|
||||
api: (any WildPhotographerReportServing)? = nil,
|
||||
uploader: (any WildReportAttachmentUploading)? = nil,
|
||||
onSupplementSuccess: (() -> Void)? = nil
|
||||
) {
|
||||
self.viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel)
|
||||
self.api = api
|
||||
self.uploader = uploader
|
||||
self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
|
||||
self.uploader = uploader ?? NetworkServices.shared.ossUploadService
|
||||
self.onSupplementSuccess = onSupplementSuccess
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user