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>
This commit is contained in:
2026-06-26 15:16:12 +08:00
parent 9edf993432
commit d99a5b1bf8
124 changed files with 5195 additions and 1536 deletions

View File

@ -6,39 +6,191 @@
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 tableView: UITableView = {
let table = UITableView(frame: .zero, style: .grouped)
table.backgroundColor = AppDesignUIKit.pageBackground
table.dataSource = self
table.delegate = self
table.register(StatisticsSummaryCell.self, forCellReuseIdentifier: StatisticsSummaryCell.reuseID)
table.register(StatisticsDailyCell.self, forCellReuseIdentifier: StatisticsDailyCell.reuseID)
table.register(StatisticsPeriodCell.self, forCellReuseIdentifier: StatisticsPeriodCell.reuseID)
return table
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
view.addSubview(tableView)
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
configureDataSource()
view.addSubview(collectionView)
collectionView.snp.makeConstraints { make in make.edges.equalToSuperview() }
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
tableView.refreshControl = refreshControl
collectionView.refreshControl = refreshControl
viewModel.onChange = { [weak self] in self?.tableView.reloadData() }
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)
@ -49,9 +201,10 @@ final class StatisticsViewController: UIViewController {
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 {
tableView.reloadData()
applySnapshot()
return
}
do {
@ -68,6 +221,7 @@ final class StatisticsViewController: UIViewController {
}
}
///
private func selectPeriod(_ period: StatisticsPeriod) {
Task {
do {
@ -85,6 +239,7 @@ final class StatisticsViewController: UIViewController {
}
}
///
private func loadMore() async {
do {
try await viewModel.loadMore(
@ -97,85 +252,37 @@ final class StatisticsViewController: UIViewController {
}
}
///
private func amountText(_ value: Double) -> String {
"¥\(String(format: "%.2f", value))"
}
}
extension StatisticsViewController: UITableViewDataSource, UITableViewDelegate {
func numberOfSections(in tableView: UITableView) -> Int {
currentScenicId == nil ? 1 : 3
}
// MARK: - UICollectionViewDelegate
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if currentScenicId == nil { return 1 }
switch section {
case 0: return 1
case 1: return 1
default:
if viewModel.loading && viewModel.dailyItems.isEmpty { return 0 }
return max(viewModel.dailyItems.count, 1)
}
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
if currentScenicId == nil {
let cell = UITableViewCell()
cell.selectionStyle = .none
cell.backgroundColor = .clear
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
let empty = makeEmptyStateView(title: "缺少经营上下文", message: "请先在首页选择景区后查看数据看板。", systemImage: "chart.bar.doc.horizontal")
cell.contentView.addSubview(empty)
empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(360) }
return cell
}
switch indexPath.section {
case 0:
let cell = tableView.dequeueReusableCell(withIdentifier: StatisticsPeriodCell.reuseID, for: indexPath) as! StatisticsPeriodCell
cell.configure(selectedPeriod: viewModel.selectedPeriod) { [weak self] period in
self?.selectPeriod(period)
}
return cell
case 1:
let cell = tableView.dequeueReusableCell(withIdentifier: StatisticsSummaryCell.reuseID, for: indexPath) as! StatisticsSummaryCell
cell.configure(
periodText: viewModel.selectedPeriod.selectedTimeText,
summary: viewModel.summary,
amountText: amountText
)
return cell
default:
if viewModel.dailyItems.isEmpty {
let cell = UITableViewCell()
cell.textLabel?.text = "暂无数据"
cell.selectionStyle = .none
return cell
}
let cell = tableView.dequeueReusableCell(withIdentifier: StatisticsDailyCell.reuseID, for: indexPath) as! StatisticsDailyCell
cell.configure(item: viewModel.dailyItems[indexPath.row], amountText: amountText)
return cell
}
}
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
section == 2 ? "每日明细" : nil
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard indexPath.section == 2, indexPath.row == viewModel.dailyItems.count - 1 else { return }
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() }
}
}
private final class StatisticsPeriodCell: UITableViewCell {
// MARK: - Cells
/// Cell
private final class StatisticsPeriodCell: UICollectionViewCell {
static let reuseID = "StatisticsPeriodCell"
private var onSelect: ((StatisticsPeriod) -> Void)?
private let stack = UIStackView()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
///
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
stack.axis = .horizontal
stack.spacing = 8
stack.distribution = .fillEqually
@ -186,6 +293,7 @@ private final class StatisticsPeriodCell: UITableViewCell {
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
///
func configure(selectedPeriod: StatisticsPeriod, onSelect: @escaping (StatisticsPeriod) -> Void) {
self.onSelect = onSelect
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
@ -202,19 +310,22 @@ private final class StatisticsPeriodCell: UITableViewCell {
}
}
///
@objc private func periodTapped(_ sender: UIButton) {
let period = StatisticsPeriod.allCases[sender.tag]
onSelect?(period)
}
}
private final class StatisticsSummaryCell: UITableViewCell {
/// Cell
private final class StatisticsSummaryCell: UICollectionViewCell {
static let reuseID = "StatisticsSummaryCell"
private let stack = UIStackView()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
///
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
stack.axis = .vertical
stack.spacing = 8
contentView.addSubview(stack)
@ -224,6 +335,7 @@ private final class StatisticsSummaryCell: UITableViewCell {
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
///
func configure(periodText: String, summary: StatisticsSummaryResponse, amountText: (Double) -> String) {
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
let dateLabel = UILabel()
@ -249,6 +361,7 @@ private final class StatisticsSummaryCell: UITableViewCell {
stack.addArrangedSubview(row2)
}
///
private func summaryCard(_ title: String, _ value: String, _ color: UIColor) -> UIView {
let card = UIView()
card.backgroundColor = color.withAlphaComponent(0.08)
@ -270,21 +383,94 @@ private final class StatisticsSummaryCell: UITableViewCell {
}
}
private final class StatisticsDailyCell: UITableViewCell {
/// Cell
private final class StatisticsDailyCell: UICollectionViewCell {
static let reuseID = "StatisticsDailyCell"
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
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) {
textLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold)
detailTextLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
textLabel?.text = item.date
detailTextLabel?.text = "\(item.orderCount)单 · 实收 \(amountText(item.receivedValue))"
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
}
}