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