Files
suixinkan_ios_uikit/suixinkan_ios/Features/Statistics/ViewControllers/StatisticsViewController.swift
汉秋 d99a5b1bf8 Advance UIKit rewrite with AMap integration and core UI modules.
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>
2026-06-26 15:16:12 +08:00

477 lines
19 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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
}
}