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

@ -80,7 +80,7 @@ enum StatisticsPeriod: String, CaseIterable, Identifiable {
}
/// 退
struct StatisticsSummaryResponse: Decodable, Equatable {
struct StatisticsSummaryResponse: Decodable, Equatable, Hashable {
let orderAmountSum: String
let orderCount: Int
let orderPriceAverage: String
@ -92,6 +92,7 @@ struct StatisticsSummaryResponse: Decodable, Equatable {
var receivedAmountValue: Double { Self.parseAmount(receivedAmountSum) }
var refundAmountValue: Double { Self.parseAmount(refundAmountSum) }
///
enum CodingKeys: String, CodingKey {
case orderAmountSum = "order_amount_sum"
case orderCount = "order_count"
@ -125,6 +126,7 @@ struct StatisticsSummaryResponse: Decodable, Equatable {
refundAmountSum = try container.decodeLossyString(forKey: .refundAmountSum)
}
/// Amount
private static func parseAmount(_ text: String) -> Double {
let normalized = text.filter { "0123456789.-".contains($0) }
return Double(normalized) ?? 0
@ -132,7 +134,7 @@ struct StatisticsSummaryResponse: Decodable, Equatable {
}
/// 退
struct StatisticsDailyItem: Decodable, Identifiable, Equatable {
struct StatisticsDailyItem: Decodable, Identifiable, Equatable, Hashable {
var id: String { date }
let date: String
@ -145,6 +147,7 @@ struct StatisticsDailyItem: Decodable, Identifiable, Equatable {
var refundValue: Double { Self.parseAmount(refund) }
var receivedValue: Double { Self.parseAmount(received) }
///
enum CodingKeys: String, CodingKey {
case date
case orderCount = "order_count"
@ -163,6 +166,7 @@ struct StatisticsDailyItem: Decodable, Identifiable, Equatable {
received = try container.decodeLossyString(forKey: .received)
}
/// Amount
private static func parseAmount(_ text: String) -> Double {
let normalized = text.filter { "0123456789.-".contains($0) }
return Double(normalized) ?? 0

View File

@ -8,7 +8,7 @@ Statistics 模块负责登录后的数据 Tab展示当前景区下的订单
## 核心对象
- `StatisticsView`:数据 Tab 根视图,读取当前景区、当前角色和统计 API
- `StatisticsViewController`:数据 Tab 根页面,使用 `UICollectionView` + Diffable Data Source 展示多 section 看板
- `StatisticsViewModel`:管理时间段、汇总数据、每日明细、分页状态和加载状态。
- `StatisticsAPI`:封装摄影师和景区管理员两套统计接口。
- `StatisticsPeriod`表示今日、昨日、7日、本月四个快捷时间段。
@ -24,3 +24,9 @@ Statistics 模块负责登录后的数据 Tab展示当前景区下的订单
## 分页规则
每日明细第一页随刷新或时间段切换加载。列表滚动到底部后,如果当前数量小于 total则继续加载下一页。加载更多失败时保留已有数据和当前页状态。
## 列表实现
- Section`empty`(无景区)、`period`(周期切换)、`summary`(汇总卡)、`daily`(每日明细,带 section 头「每日明细」)
- 使用 `UICollectionViewDiffableDataSource` + `NSDiffableDataSourceSnapshot` 驱动刷新,分页追加时保留插入动画
- 布局复用 `CollectionDiffableLayout` 全宽 section最后一项日明细 `willDisplay` 时触发 `loadMore`

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