Integrate高德 SDK with simulator-safe build flags, add map views for operating area and punch points, refactor main tabs and key feature screens to UIKit with Diffable lists, and document Swift concurrency defaults in AGENTS.md. Co-authored-by: Cursor <cursoragent@cursor.com>
477 lines
19 KiB
Swift
477 lines
19 KiB
Swift
//
|
||
// StatisticsViewController.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import SnapKit
|
||
import UIKit
|
||
|
||
// MARK: - Diffable 标识
|
||
|
||
/// 统计页 section 标识,区分空状态、周期切换、汇总与每日明细。
|
||
private enum StatisticsSection: Hashable {
|
||
case empty
|
||
case period
|
||
case summary
|
||
case daily
|
||
}
|
||
|
||
/// 统计页 item 标识,携带展示数据以保证 diff 后 Cell 内容同步更新。
|
||
private enum StatisticsItem: Hashable {
|
||
case emptyContext
|
||
case period(StatisticsPeriod)
|
||
case summary(StatisticsSummaryResponse)
|
||
case dailyEmpty
|
||
case daily(StatisticsDailyItem)
|
||
}
|
||
|
||
/// 数据 Tab 根页面,展示订单统计汇总和每日明细。
|
||
final class StatisticsViewController: UIViewController {
|
||
|
||
private let viewModel = StatisticsViewModel()
|
||
|
||
private lazy var collectionView: UICollectionView = {
|
||
let layout = makeCollectionLayout()
|
||
let collection = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||
collection.backgroundColor = AppDesignUIKit.pageBackground
|
||
collection.delegate = self
|
||
collection.register(StatisticsPeriodCell.self, forCellWithReuseIdentifier: StatisticsPeriodCell.reuseID)
|
||
collection.register(StatisticsSummaryCell.self, forCellWithReuseIdentifier: StatisticsSummaryCell.reuseID)
|
||
collection.register(StatisticsDailyCell.self, forCellWithReuseIdentifier: StatisticsDailyCell.reuseID)
|
||
collection.register(StatisticsEmptyCell.self, forCellWithReuseIdentifier: StatisticsEmptyCell.reuseID)
|
||
collection.register(
|
||
CollectionSectionHeaderView.self,
|
||
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
|
||
withReuseIdentifier: CollectionSectionHeaderView.reuseID
|
||
)
|
||
return collection
|
||
}()
|
||
|
||
private lazy var refreshControl = UIRefreshControl()
|
||
|
||
/// Diffable 数据源,驱动多 section 列表与分页插入动画。
|
||
private var dataSource: UICollectionViewDiffableDataSource<StatisticsSection, StatisticsItem>!
|
||
|
||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
title = "数据"
|
||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||
configureDataSource()
|
||
view.addSubview(collectionView)
|
||
collectionView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||
collectionView.refreshControl = refreshControl
|
||
|
||
viewModel.onChange = { [weak self] in self?.applySnapshot() }
|
||
appServices.accountContext.onChange = { [weak self] in Task { await self?.reload(showLoading: true) } }
|
||
appServices.permissionContext.onChange = { [weak self] in Task { await self?.reload(showLoading: true) } }
|
||
applySnapshot(animated: false)
|
||
Task { await reload(showLoading: true) }
|
||
}
|
||
|
||
/// 构建 Compositional Layout,按 section 类型分配全宽卡片布局。
|
||
private func makeCollectionLayout() -> UICollectionViewCompositionalLayout {
|
||
UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in
|
||
guard let self else { return CollectionDiffableLayout.fullWidthSection() }
|
||
let snapshot = self.dataSource?.snapshot()
|
||
let section = snapshot?.sectionIdentifiers[safe: sectionIndex]
|
||
switch section {
|
||
case .empty:
|
||
return CollectionDiffableLayout.fullWidthSection(estimatedHeight: 360)
|
||
case .period:
|
||
return CollectionDiffableLayout.fullWidthSection(estimatedHeight: 56)
|
||
case .summary:
|
||
return CollectionDiffableLayout.fullWidthSection(estimatedHeight: 280)
|
||
case .daily:
|
||
return CollectionDiffableLayout.addHeader(
|
||
to: CollectionDiffableLayout.fullWidthSection(estimatedHeight: 52),
|
||
height: 36
|
||
)
|
||
case .none:
|
||
return CollectionDiffableLayout.fullWidthSection()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 注册 Diffable 数据源与 Cell 配置闭包。
|
||
private func configureDataSource() {
|
||
dataSource = UICollectionViewDiffableDataSource<StatisticsSection, StatisticsItem>(
|
||
collectionView: collectionView
|
||
) { [weak self] collectionView, indexPath, item in
|
||
guard let self else { return UICollectionViewCell() }
|
||
switch item {
|
||
case .emptyContext:
|
||
let cell = collectionView.dequeueReusableCell(
|
||
withReuseIdentifier: StatisticsEmptyCell.reuseID,
|
||
for: indexPath
|
||
) as! StatisticsEmptyCell
|
||
let empty = self.makeEmptyStateView(
|
||
title: "缺少经营上下文",
|
||
message: "请先在首页选择景区后查看数据看板。",
|
||
systemImage: "chart.bar.doc.horizontal"
|
||
)
|
||
cell.setHostedView(empty, height: 360)
|
||
return cell
|
||
case .period(let selectedPeriod):
|
||
let cell = collectionView.dequeueReusableCell(
|
||
withReuseIdentifier: StatisticsPeriodCell.reuseID,
|
||
for: indexPath
|
||
) as! StatisticsPeriodCell
|
||
cell.configure(selectedPeriod: selectedPeriod) { [weak self] period in
|
||
self?.selectPeriod(period)
|
||
}
|
||
return cell
|
||
case .summary(let summary):
|
||
let cell = collectionView.dequeueReusableCell(
|
||
withReuseIdentifier: StatisticsSummaryCell.reuseID,
|
||
for: indexPath
|
||
) as! StatisticsSummaryCell
|
||
cell.configure(
|
||
periodText: self.viewModel.selectedPeriod.selectedTimeText,
|
||
summary: summary,
|
||
amountText: self.amountText
|
||
)
|
||
return cell
|
||
case .dailyEmpty:
|
||
let cell = collectionView.dequeueReusableCell(
|
||
withReuseIdentifier: StatisticsEmptyCell.reuseID,
|
||
for: indexPath
|
||
) as! StatisticsEmptyCell
|
||
cell.configurePlainText("暂无数据", height: 52)
|
||
return cell
|
||
case .daily(let dailyItem):
|
||
let cell = collectionView.dequeueReusableCell(
|
||
withReuseIdentifier: StatisticsDailyCell.reuseID,
|
||
for: indexPath
|
||
) as! StatisticsDailyCell
|
||
cell.configure(item: dailyItem, amountText: self.amountText)
|
||
return cell
|
||
}
|
||
}
|
||
|
||
dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in
|
||
guard kind == UICollectionView.elementKindSectionHeader,
|
||
let section = self.dataSource.snapshot().sectionIdentifiers[safe: indexPath.section],
|
||
section == .daily,
|
||
let header = collectionView.dequeueReusableSupplementaryView(
|
||
ofKind: kind,
|
||
withReuseIdentifier: CollectionSectionHeaderView.reuseID,
|
||
for: indexPath
|
||
) as? CollectionSectionHeaderView
|
||
else { return nil }
|
||
header.configure(title: "每日明细")
|
||
return header
|
||
}
|
||
}
|
||
|
||
/// 根据 ViewModel 状态构建 snapshot 并应用 diff 更新。
|
||
private func applySnapshot(animated: Bool = true) {
|
||
var snapshot = NSDiffableDataSourceSnapshot<StatisticsSection, StatisticsItem>()
|
||
|
||
if currentScenicId == nil {
|
||
snapshot.appendSections([.empty])
|
||
snapshot.appendItems([.emptyContext], toSection: .empty)
|
||
} else {
|
||
snapshot.appendSections([.period, .summary, .daily])
|
||
snapshot.appendItems([.period(viewModel.selectedPeriod)], toSection: .period)
|
||
snapshot.appendItems([.summary(viewModel.summary)], toSection: .summary)
|
||
|
||
if viewModel.loading, viewModel.dailyItems.isEmpty {
|
||
// 首次加载中不展示占位行,避免闪烁。
|
||
} else if viewModel.dailyItems.isEmpty {
|
||
snapshot.appendItems([.dailyEmpty], toSection: .daily)
|
||
} else {
|
||
let items = viewModel.dailyItems.map { StatisticsItem.daily($0) }
|
||
snapshot.appendItems(items, toSection: .daily)
|
||
}
|
||
}
|
||
|
||
dataSource.apply(snapshot, animatingDifferences: animated)
|
||
}
|
||
|
||
/// 下拉刷新触发。
|
||
@objc private func refreshPulled() {
|
||
Task {
|
||
await reload(showLoading: false)
|
||
refreshControl.endRefreshing()
|
||
}
|
||
}
|
||
|
||
private var currentScenicId: Int? { appServices.accountContext.currentScenic?.id }
|
||
private var currentRoleId: Int? { appServices.permissionContext.currentRole?.id }
|
||
|
||
/// 重新加载汇总与第一页日数据。
|
||
private func reload(showLoading: Bool) async {
|
||
guard currentScenicId != nil else {
|
||
applySnapshot()
|
||
return
|
||
}
|
||
do {
|
||
try await appServices.globalLoading.withOptionalLoading(showLoading, message: "加载数据...") {
|
||
try await self.viewModel.reload(
|
||
api: self.appServices.statisticsAPI,
|
||
scenicId: self.currentScenicId,
|
||
roleId: self.currentRoleId,
|
||
showLoading: false
|
||
)
|
||
}
|
||
} catch {
|
||
showToast(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// 切换统计周期并重新加载数据。
|
||
private func selectPeriod(_ period: StatisticsPeriod) {
|
||
Task {
|
||
do {
|
||
try await appServices.globalLoading.withLoading(message: "加载数据...") {
|
||
try await viewModel.selectPeriod(
|
||
period,
|
||
api: appServices.statisticsAPI,
|
||
scenicId: currentScenicId,
|
||
roleId: currentRoleId
|
||
)
|
||
}
|
||
} catch {
|
||
showToast(error.localizedDescription)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 加载每日明细下一页。
|
||
private func loadMore() async {
|
||
do {
|
||
try await viewModel.loadMore(
|
||
api: appServices.statisticsAPI,
|
||
scenicId: currentScenicId,
|
||
roleId: currentRoleId
|
||
)
|
||
} catch {
|
||
showToast(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// 格式化金额展示文本。
|
||
private func amountText(_ value: Double) -> String {
|
||
"¥\(String(format: "%.2f", value))"
|
||
}
|
||
}
|
||
|
||
// MARK: - UICollectionViewDelegate
|
||
|
||
extension StatisticsViewController: UICollectionViewDelegate {
|
||
/// 最后一项日明细即将展示时触发分页加载。
|
||
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
|
||
guard let item = dataSource.itemIdentifier(for: indexPath),
|
||
case .daily(let dailyItem) = item,
|
||
dailyItem.date == viewModel.dailyItems.last?.date
|
||
else { return }
|
||
Task { await loadMore() }
|
||
}
|
||
}
|
||
|
||
// MARK: - Cells
|
||
|
||
/// 统计周期切换 Cell,横向展示快捷时间段按钮。
|
||
private final class StatisticsPeriodCell: UICollectionViewCell {
|
||
static let reuseID = "StatisticsPeriodCell"
|
||
private var onSelect: ((StatisticsPeriod) -> Void)?
|
||
private let stack = UIStackView()
|
||
|
||
/// 初始化实例。
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
backgroundColor = .clear
|
||
stack.axis = .horizontal
|
||
stack.spacing = 8
|
||
stack.distribution = .fillEqually
|
||
contentView.addSubview(stack)
|
||
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(12) }
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) { fatalError() }
|
||
|
||
/// 配置展示内容。
|
||
func configure(selectedPeriod: StatisticsPeriod, onSelect: @escaping (StatisticsPeriod) -> Void) {
|
||
self.onSelect = onSelect
|
||
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||
for period in StatisticsPeriod.allCases {
|
||
let button = UIButton(type: .system)
|
||
button.setTitle(period.rawValue, for: .normal)
|
||
button.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: period == selectedPeriod ? .semibold : .regular)
|
||
button.setTitleColor(period == selectedPeriod ? AppDesignUIKit.primary : AppDesignUIKit.textSecondary, for: .normal)
|
||
button.backgroundColor = period == selectedPeriod ? AppDesignUIKit.primarySoft : UIColor(hex: 0xF4F4F4)
|
||
button.layer.cornerRadius = 6
|
||
button.tag = StatisticsPeriod.allCases.firstIndex(of: period) ?? 0
|
||
button.addTarget(self, action: #selector(periodTapped(_:)), for: .touchUpInside)
|
||
stack.addArrangedSubview(button)
|
||
}
|
||
}
|
||
|
||
/// 点击周期按钮后回调选中项。
|
||
@objc private func periodTapped(_ sender: UIButton) {
|
||
let period = StatisticsPeriod.allCases[sender.tag]
|
||
onSelect?(period)
|
||
}
|
||
}
|
||
|
||
/// 统计汇总 Cell,展示订单金额、数量、客单价等指标卡片。
|
||
private final class StatisticsSummaryCell: UICollectionViewCell {
|
||
static let reuseID = "StatisticsSummaryCell"
|
||
private let stack = UIStackView()
|
||
|
||
/// 初始化实例。
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
backgroundColor = .clear
|
||
stack.axis = .vertical
|
||
stack.spacing = 8
|
||
contentView.addSubview(stack)
|
||
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(12) }
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) { fatalError() }
|
||
|
||
/// 配置展示内容。
|
||
func configure(periodText: String, summary: StatisticsSummaryResponse, amountText: (Double) -> String) {
|
||
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||
let dateLabel = UILabel()
|
||
dateLabel.text = "已选日期:\(periodText)"
|
||
dateLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||
stack.addArrangedSubview(dateLabel)
|
||
|
||
stack.addArrangedSubview(summaryCard("订单总金额", amountText(summary.orderAmountValue), UIColor(hex: 0x22C55E)))
|
||
let row = UIStackView()
|
||
row.axis = .horizontal
|
||
row.spacing = 8
|
||
row.distribution = .fillEqually
|
||
row.addArrangedSubview(summaryCard("订单总数", "\(summary.orderCount)单", UIColor(hex: 0x7F00FF)))
|
||
row.addArrangedSubview(summaryCard("实收金额", amountText(summary.receivedAmountValue), UIColor(hex: 0x22C55E)))
|
||
stack.addArrangedSubview(row)
|
||
|
||
let row2 = UIStackView()
|
||
row2.axis = .horizontal
|
||
row2.spacing = 8
|
||
row2.distribution = .fillEqually
|
||
row2.addArrangedSubview(summaryCard("客单价", amountText(summary.orderPriceAverageValue), AppDesignUIKit.primary))
|
||
row2.addArrangedSubview(summaryCard("退款金额", amountText(summary.refundAmountValue), UIColor(hex: 0xEF4444)))
|
||
stack.addArrangedSubview(row2)
|
||
}
|
||
|
||
/// 创建统计摘要卡片视图。
|
||
private func summaryCard(_ title: String, _ value: String, _ color: UIColor) -> UIView {
|
||
let card = UIView()
|
||
card.backgroundColor = color.withAlphaComponent(0.08)
|
||
card.layer.cornerRadius = 8
|
||
let titleLabel = UILabel()
|
||
titleLabel.text = title
|
||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
|
||
let valueLabel = UILabel()
|
||
valueLabel.text = value
|
||
valueLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title3, weight: .bold)
|
||
valueLabel.textColor = color
|
||
let inner = UIStackView(arrangedSubviews: [titleLabel, valueLabel])
|
||
inner.axis = .vertical
|
||
inner.spacing = 4
|
||
card.addSubview(inner)
|
||
inner.snp.makeConstraints { make in make.edges.equalToSuperview().inset(12) }
|
||
card.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(72) }
|
||
return card
|
||
}
|
||
}
|
||
|
||
/// 每日统计明细 Cell,展示日期、订单数与实收金额。
|
||
private final class StatisticsDailyCell: UICollectionViewCell {
|
||
static let reuseID = "StatisticsDailyCell"
|
||
|
||
private let titleLabel = UILabel()
|
||
private let detailLabel = UILabel()
|
||
|
||
/// 初始化实例。
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
backgroundColor = .secondarySystemGroupedBackground
|
||
contentView.backgroundColor = .secondarySystemGroupedBackground
|
||
contentView.layer.cornerRadius = 8
|
||
contentView.clipsToBounds = true
|
||
|
||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold)
|
||
detailLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
|
||
detailLabel.textColor = AppDesignUIKit.textSecondary
|
||
|
||
contentView.addSubview(titleLabel)
|
||
contentView.addSubview(detailLabel)
|
||
titleLabel.snp.makeConstraints { make in
|
||
make.leading.trailing.equalToSuperview().inset(16)
|
||
make.top.equalToSuperview().inset(12)
|
||
}
|
||
detailLabel.snp.makeConstraints { make in
|
||
make.leading.trailing.equalTo(titleLabel)
|
||
make.top.equalTo(titleLabel.snp.bottom).offset(4)
|
||
make.bottom.equalToSuperview().inset(12)
|
||
}
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) { fatalError() }
|
||
|
||
/// 配置展示内容。
|
||
func configure(item: StatisticsDailyItem, amountText: (Double) -> String) {
|
||
titleLabel.text = item.date
|
||
detailLabel.text = "\(item.orderCount)单 · 实收 \(amountText(item.receivedValue))"
|
||
}
|
||
}
|
||
|
||
/// 空状态 Cell,用于缺少景区上下文或日明细为空时的占位展示。
|
||
private final class StatisticsEmptyCell: UICollectionViewCell {
|
||
static let reuseID = "StatisticsEmptyCell"
|
||
|
||
private var heightConstraint: Constraint?
|
||
|
||
/// 初始化实例。
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
backgroundColor = .clear
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) { fatalError() }
|
||
|
||
/// 嵌入外部构建的空状态视图并固定高度。
|
||
func setHostedView(_ view: UIView, height: CGFloat) {
|
||
contentView.subviews.forEach { $0.removeFromSuperview() }
|
||
heightConstraint?.deactivate()
|
||
contentView.addSubview(view)
|
||
view.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview()
|
||
heightConstraint = make.height.equalTo(height).constraint
|
||
}
|
||
}
|
||
|
||
/// 配置纯文案占位,用于日明细为空场景。
|
||
func configurePlainText(_ title: String, height: CGFloat) {
|
||
contentView.subviews.forEach { $0.removeFromSuperview() }
|
||
heightConstraint?.deactivate()
|
||
let label = UILabel()
|
||
label.text = title
|
||
label.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||
label.textColor = AppDesignUIKit.textSecondary
|
||
label.textAlignment = .center
|
||
contentView.addSubview(label)
|
||
label.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview().inset(16)
|
||
heightConstraint = make.height.equalTo(height).constraint
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 安全下标,避免 section 越界。
|
||
private extension Array {
|
||
subscript(safe index: Int) -> Element? {
|
||
indices.contains(index) ? self[index] : nil
|
||
}
|
||
}
|