// // 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! /// 视图加载完成后的 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( 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() 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 } }