Files
suixinkan_ios_uikit/suixinkan_ios/Features/Statistics/ViewControllers/StatisticsViewController.swift
2026-06-26 14:33:31 +08:00

291 lines
12 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
/// 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))"
}
}