// // StatisticsViewController.swift // suixinkan // import SnapKit import UIKit /// 数据 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 refreshControl = UIRefreshControl() override func viewDidLoad() { super.viewDidLoad() title = "数据" view.backgroundColor = AppDesignUIKit.pageBackground view.addSubview(tableView) tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) tableView.refreshControl = refreshControl viewModel.onChange = { [weak self] in self?.tableView.reloadData() } appServices.accountContext.onChange = { [weak self] in Task { await self?.reload(showLoading: true) } } appServices.permissionContext.onChange = { [weak self] in Task { await self?.reload(showLoading: true) } } Task { await reload(showLoading: true) } } @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 { tableView.reloadData() 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))" } } extension StatisticsViewController: UITableViewDataSource, UITableViewDelegate { func numberOfSections(in tableView: UITableView) -> Int { currentScenicId == nil ? 1 : 3 } 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 } Task { await loadMore() } } } private final class StatisticsPeriodCell: UITableViewCell { 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 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) } } private final class StatisticsSummaryCell: UITableViewCell { static let reuseID = "StatisticsSummaryCell" private let stack = UIStackView() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none 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 } } private final class StatisticsDailyCell: UITableViewCell { static let reuseID = "StatisticsDailyCell" override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none } @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))" } }