重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
165 lines
7.3 KiB
Swift
165 lines
7.3 KiB
Swift
//
|
||
// ScenicQueueSettingChangeLogViewController.swift
|
||
// suixinkan
|
||
//
|
||
|
||
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) {
|
||
self.api = api ?? NetworkServices.shared.scenicQueueAPI
|
||
super.init(nibName: nil, bundle: nil)
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
Task { await viewModel.refresh(api: api) }
|
||
}
|
||
|
||
override func setupNavigationBar() { title = "配置日志" }
|
||
|
||
override func setupUI() {
|
||
view.backgroundColor = .white
|
||
tableView.backgroundColor = .white
|
||
tableView.separatorStyle = .none
|
||
tableView.rowHeight = UITableView.automaticDimension
|
||
tableView.estimatedRowHeight = 132
|
||
tableView.delegate = self
|
||
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.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 { $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) } }
|
||
}
|
||
|
||
private func applyState() {
|
||
tableView.refreshControl?.endRefreshing()
|
||
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
|
||
}
|
||
}
|
||
|
||
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: UITableViewDelegate {
|
||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||
guard !viewModel.items.isEmpty,
|
||
scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 180 else { return }
|
||
Task { await viewModel.loadMore(api: api) }
|
||
}
|
||
}
|
||
|
||
/// 配置日志的时间轴卡片单元格。
|
||
final class ScenicQueueLogTimelineCell: UITableViewCell {
|
||
static let reuseIdentifier = "ScenicQueueLogTimelineCell"
|
||
private let rail = UIView()
|
||
private let dot = UIView()
|
||
private let card = UIView()
|
||
private let metaLabel = UILabel()
|
||
private let summaryLabel = UILabel()
|
||
|
||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||
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") }
|
||
|
||
/// 绑定日志内容与时间轴圆点显隐。
|
||
func configure(item: ScenicQueueSettingChangeLogItem, showDot: Bool) {
|
||
metaLabel.text = item.metadataLine
|
||
summaryLabel.text = item.cardSummary
|
||
dot.isHidden = !showDot
|
||
}
|
||
}
|