Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View File

@ -0,0 +1,290 @@
//
// 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))"
}
}